From 8234e160b11b316b02d3cef56b2509a59718da19 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 11 Jun 2026 10:45:18 -0700 Subject: [PATCH 1/6] [Native] MultiRenderTarget, reverse-Z clear, applyStates, OIT alpha modes Foundational native-engine surface so MultiRenderTarget, the reverse depth buffer, and the order-independent-transparency renderer stop dereferencing the null _gl context. Removes several crash classes; does not by itself land the dependent validation tests. - createMultipleRenderTarget + the MRT helper overrides (bindAttachments, buildTextureLayout, restoreSingleAttachment[ForRenderTarget], generateMipMapsMultiFramebuffer, resolveMultiFramebuffer, unBindMultiColorAttachmentFramebuffer, updateMultipleRenderTargetTextureSampleCount). bgfx writes every color attachment of the bound framebuffer, so the WebGL drawBuffers / MSAA-resolve plumbing becomes no-ops on Native. Reports drawBuffersExtension = true. - clear(): implement the reverse depth buffer (clear depth to 0 + GEQUAL) instead of throwing. - applyStates(): override so it flushes the depth-culling state through the native command path (the base implementation drives the null _gl directly), fixing callers that mutate engine.depthCullingState then call applyStates() (e.g. the OIT depth-peeling renderer). - Map alpha modes ALPHA_ONEONE_ONEONE and ALPHA_LAYER_ACCUMULATE to the native engine. Pairs with the BabylonNative change (createMultiFrameBuffer + native alpha modes). Known follow-ups: OIT depth-peeling still faults in the D3D11 driver on submit; the blend equation (MAX) is not yet applied natively. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/src/Engines/Native/nativeHelpers.ts | 4 + .../src/Engines/Native/nativeInterfaces.ts | 10 + .../core/src/Engines/thinNativeEngine.pure.ts | 181 +++++++++++++++++- 3 files changed, 191 insertions(+), 4 deletions(-) diff --git a/packages/dev/core/src/Engines/Native/nativeHelpers.ts b/packages/dev/core/src/Engines/Native/nativeHelpers.ts index 751262a02794..1b8c085976cb 100644 --- a/packages/dev/core/src/Engines/Native/nativeHelpers.ts +++ b/packages/dev/core/src/Engines/Native/nativeHelpers.ts @@ -315,6 +315,10 @@ export function getNativeAlphaMode(mode: number): number { return _native.Engine.ALPHA_MAXIMIZED; case Constants.ALPHA_ONEONE: return _native.Engine.ALPHA_ONEONE; + case Constants.ALPHA_ONEONE_ONEONE: + return _native.Engine.ALPHA_ONEONE_ONEONE; + case Constants.ALPHA_LAYER_ACCUMULATE: + return _native.Engine.ALPHA_LAYER_ACCUMULATE; case Constants.ALPHA_PREMULTIPLIED: return _native.Engine.ALPHA_PREMULTIPLIED; case Constants.ALPHA_PREMULTIPLIED_PORTERDUFF: diff --git a/packages/dev/core/src/Engines/Native/nativeInterfaces.ts b/packages/dev/core/src/Engines/Native/nativeInterfaces.ts index 90e077e9decd..d461b64ccc98 100644 --- a/packages/dev/core/src/Engines/Native/nativeInterfaces.ts +++ b/packages/dev/core/src/Engines/Native/nativeInterfaces.ts @@ -125,6 +125,14 @@ export interface INativeEngine { samples: number, layer?: number ): NativeFramebuffer; + createMultiFrameBuffer( + textures: NativeTexture[], + width: number, + height: number, + generateStencilBuffer: boolean, + generateDepthBuffer: boolean, + samples: number + ): NativeFramebuffer; getRenderWidth(): number; getRenderHeight(): number; @@ -293,6 +301,8 @@ interface INativeEngineConstructor { readonly ALPHA_MULTIPLY: number; readonly ALPHA_MAXIMIZED: number; readonly ALPHA_ONEONE: number; + readonly ALPHA_ONEONE_ONEONE: number; + readonly ALPHA_LAYER_ACCUMULATE: number; readonly ALPHA_PREMULTIPLIED: number; readonly ALPHA_PREMULTIPLIED_PORTERDUFF: number; readonly ALPHA_INTERPOLATE: number; diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index bfc26f3b8bc5..6a5db3b0e111 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -19,6 +19,7 @@ import { type InternalTextureCreationOptions, } from "../Materials/Textures/textureCreationOptions"; import { type IPipelineContext } from "./IPipelineContext"; +import { type IMultiRenderTargetOptions } from "../Materials/Textures/multiRenderTarget.pure"; import { type IColor3Like, type IColor4Like, type IViewportLike } from "../Maths/math.like"; import { Logger } from "../Misc/logger"; import { Constants } from "./constants"; @@ -350,7 +351,7 @@ export class ThinNativeEngine extends ThinEngine { textureHalfFloatRender: true, textureLOD: true, texelFetch: false, - drawBuffersExtension: false, + drawBuffersExtension: true, depthTextureExtension: false, vertexArrayObject: true, instancedArrays: true, @@ -576,8 +577,11 @@ export class ThinNativeEngine extends ThinEngine { } public override clear(color: Nullable, backBuffer: boolean, depth: boolean, stencil: boolean = false, stencilClearValue = 0): void { - if (this.useReverseDepthBuffer) { - throw new Error("reverse depth buffer is not currently implemented"); + if (depth && this.useReverseDepthBuffer) { + // Reverse-Z: the scene is rendered with a flipped projection (near maps to 1, far to 0), so the + // depth buffer is cleared to 0 and the comparison must accept greater values. Mirror the WebGL + // engine, which sets the depth-culling comparison to GEQUAL here and clears depth to 0. + this._depthCullingState.depthFunc = Constants.GEQUAL; } this._commandBufferEncoder.startEncodingCommand(_native.Engine.COMMAND_CLEAR); @@ -587,7 +591,7 @@ export class ThinNativeEngine extends ThinEngine { this._commandBufferEncoder.encodeCommandArgAsFloat32(color ? color.b : 0); this._commandBufferEncoder.encodeCommandArgAsFloat32(color ? color.a : 1); this._commandBufferEncoder.encodeCommandArgAsUInt32(depth ? 1 : 0); - this._commandBufferEncoder.encodeCommandArgAsFloat32(1); + this._commandBufferEncoder.encodeCommandArgAsFloat32(depth && this.useReverseDepthBuffer ? 0 : 1); this._commandBufferEncoder.encodeCommandArgAsUInt32(stencil ? 1 : 0); this._commandBufferEncoder.encodeCommandArgAsUInt32(stencilClearValue); this._commandBufferEncoder.finishEncodingCommand(); @@ -1102,6 +1106,20 @@ export class ThinNativeEngine extends ThinEngine { } } + public override applyStates(): void { + // The base ThinEngine.applyStates() drives the WebGL context (this._gl) directly, which is null on + // Native. Flush the depth-culling state through the native command path instead, so callers that + // mutate engine.depthCullingState directly and then call applyStates() (e.g. the depth-peeling / OIT + // renderer) take effect. Alpha and stencil state are applied on Native via their dedicated setters + // (setAlphaMode / setStencil*), which encode their commands when called. + const depthCullingState = this._depthCullingState; + if (depthCullingState.depthFunc !== null && depthCullingState.depthFunc !== undefined) { + this.setDepthFunction(depthCullingState.depthFunc); + } + this.setDepthBuffer(depthCullingState.depthTest); + this.setDepthWrite(depthCullingState.depthMask); + } + /** * Gets a boolean indicating if depth writing is enabled * @returns the current depth writing state @@ -2440,6 +2458,114 @@ export class ThinNativeEngine extends ThinEngine { return rtWrapper; } + public override createMultipleRenderTarget(size: TextureSize, options: IMultiRenderTargetOptions, _initializeBuffers = true): RenderTargetWrapper { + const rtWrapper = this._createHardwareRenderTargetWrapper(true, false, size) as NativeRenderTargetWrapper; + + let generateMipMaps = false; + let generateDepthBuffer = true; + let generateStencilBuffer = false; + let generateDepthTexture = false; + let textureCount = 1; + let samples = 1; + let types: number[] = []; + let samplingModes: number[] = []; + let formats: number[] = []; + let targets: number[] = []; + let faceIndex: number[] = []; + let layerIndex: number[] = []; + let labels: string[] = []; + let dontCreateTextures = false; + + if (options !== undefined) { + generateMipMaps = options.generateMipMaps ?? false; + generateDepthBuffer = options.generateDepthBuffer ?? true; + generateStencilBuffer = options.generateStencilBuffer ?? false; + generateDepthTexture = options.generateDepthTexture ?? false; + textureCount = options.textureCount ?? 1; + samples = options.samples ?? 1; + types = options.types || types; + samplingModes = options.samplingModes || samplingModes; + formats = options.formats || formats; + targets = options.targetTypes || targets; + faceIndex = options.faceIndex || faceIndex; + layerIndex = options.layerIndex || layerIndex; + labels = options.labels || labels; + dontCreateTextures = options.dontCreateTextures ?? false; + } + + rtWrapper.label = options?.label ?? "MultiRenderTargetWrapper"; + + const width = (<{ width: number; height: number }>size).width ?? size; + const height = (<{ width: number; height: number }>size).height ?? size; + + const textures: InternalTexture[] = []; + const attachments: number[] = []; + const colorHandles: NativeTexture[] = []; + + for (let i = 0; i < textureCount; i++) { + const samplingMode = samplingModes[i] || Constants.TEXTURE_TRILINEAR_SAMPLINGMODE; + let type = types[i] || Constants.TEXTURETYPE_UNSIGNED_BYTE; + const format = formats[i] || Constants.TEXTUREFORMAT_RGBA; + const target = targets[i] || Constants.TEXTURE_2D; + + attachments.push(i); + + // target === -1 marks an attachment with no engine-created texture; dontCreateTextures defers them. + if (target === -1 || dontCreateTextures) { + continue; + } + + if (type === Constants.TEXTURETYPE_FLOAT && !this._caps.textureFloat) { + type = Constants.TEXTURETYPE_UNSIGNED_BYTE; + Logger.Warn("Float textures are not supported. Multi render target attachment forced to TEXTURETYPE_UNSIGNED_BYTE type"); + } + + const texture = new InternalTexture(this, InternalTextureSource.MultiRenderTarget); + const nativeTexture = texture._hardwareTexture!.underlyingResource; + if (target !== Constants.TEXTURE_2D) { + // Native multi render targets currently only attach 2D color textures; cube / 2D-array + // attachments are created as 2D so the attachment still exists and mrt.textures[i] is populated. + Logger.Warn("Multi render target attachment target " + target + " is not supported on Native; using a 2D texture."); + } + // bgfx auto-generates the mip chain on resolve; avoid the mips + MSAA combo (see createRenderTargetTexture). + const hasMips = samples > 1 ? false : generateMipMaps; + this._engine.initializeTexture(nativeTexture, width, height, hasMips, getNativeTextureFormat(format, type), /*renderTarget*/ true, /*srgb*/ false, samples); + this._setTextureSampling(nativeTexture, getNativeSamplingMode(samplingMode)); + + texture.baseWidth = width; + texture.baseHeight = height; + texture.width = width; + texture.height = height; + texture.isReady = true; + texture.samples = samples; + texture.generateMipMaps = generateMipMaps; + texture.samplingMode = samplingMode; + texture.type = type; + texture.format = format; + texture.label = labels[i] ?? rtWrapper.label + "-Texture" + i; + + textures[i] = texture; + colorHandles.push(nativeTexture); + this._internalTexturesCache.push(texture); + } + + // The native engine creates one framebuffer with all the color attachments (bgfx writes to every + // attachment of the bound framebuffer, so there is no drawBuffers equivalent to issue per draw). + if (colorHandles.length > 0) { + rtWrapper._framebuffer = this._engine.createMultiFrameBuffer(colorHandles, width, height, generateStencilBuffer, generateDepthBuffer || generateDepthTexture, samples); + } + + rtWrapper._generateDepthBuffer = generateDepthBuffer || generateDepthTexture; + rtWrapper._generateStencilBuffer = generateStencilBuffer; + rtWrapper._samples = samples; + rtWrapper._attachments = attachments; + + rtWrapper.setTextures(textures); + rtWrapper.setLayerAndFaceIndices(layerIndex, faceIndex); + + return rtWrapper; + } + public override generateMipMapsForCubemap(_texture: InternalTexture, _unbind = true): void { // The WebGL path rebinds gl.TEXTURE_CUBE_MAP and calls gl.generateMipmap; both deref _gl, which is // null on Native. bgfx auto-generates the mip chain when a render target texture created with mips is @@ -2447,6 +2573,53 @@ export class ThinNativeEngine extends ThinEngine { // so this is a no-op on Native. } + public override bindAttachments(_attachments: number[]): void { + // No-op on Native: bgfx renders to every color attachment of the bound framebuffer, so there is + // no gl.drawBuffers equivalent to select a subset. + } + + public override buildTextureLayout(textureStatus: boolean[], _backBufferLayout = false): number[] { + // Native has no gl draw-buffer enums; return a per-attachment index list (consumers only use the + // length/order, and bindAttachments is a no-op). + const result: number[] = []; + for (let i = 0; i < textureStatus.length; i++) { + result.push(textureStatus[i] ? i : -1); + } + return result; + } + + public override restoreSingleAttachment(): void { + // No-op on Native (see bindAttachments). + } + + public override restoreSingleAttachmentForRenderTarget(): void { + // No-op on Native (see bindAttachments). + } + + public override generateMipMapsMultiFramebuffer(_texture: RenderTargetWrapper): void { + // No-op on Native: bgfx auto-generates mips on render-target resolve (as for 2D/cube RTTs). + } + + public override resolveMultiFramebuffer(_texture: RenderTargetWrapper): void { + // No-op on Native: bgfx resolves MSAA render targets automatically. + } + + public override unBindMultiColorAttachmentFramebuffer(_rtWrapper: RenderTargetWrapper, _disableGenerateMipMaps = false, onBeforeUnbind?: () => void): void { + this._currentRenderTarget = null; + if (onBeforeUnbind) { + onBeforeUnbind(); + } + this._bindUnboundFramebuffer(null); + } + + public override updateMultipleRenderTargetTextureSampleCount(rtWrapper: Nullable, samples: number, _initializeBuffers = true): number { + // Native MSAA is configured at texture creation; just record the requested sample count. + if (rtWrapper) { + (rtWrapper as NativeRenderTargetWrapper)._samples = samples; + } + return samples; + } + public override updateRenderTargetTextureSampleCount(rtWrapper: RenderTargetWrapper, samples: number): number { if (rtWrapper.samples === samples) { return samples; From 4f4e93c4ade3037a4a868960c75d9f5a1c65baee Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 8 Jul 2026 13:28:12 -0700 Subject: [PATCH 2/6] [Native] Feature-detect MRT framebuffer + OIT alpha modes for older native Address review feedback: don't hard-break against Babylon Native binaries that predate multi render target support (BabylonNative#1754). - thinNativeEngine: guard createMultiFrameBuffer; when the native binary does not expose it, warn and fall back to a single-attachment framebuffer bound to the first color target so the scene keeps rendering. - nativeHelpers.getNativeAlphaMode: ALPHA_ONEONE_ONEONE / ALPHA_LAYER_ACCUMULATE now warn once and fall back to ALPHA_ONEONE when the native constant is undefined, instead of silently returning undefined. - nativeInterfaces: mark createMultiFrameBuffer and the two OIT alpha constants optional to reflect that older native does not provide them. Keeps the PR mergeable against the currently published native binary and unblocks the Native tests CI without requiring a coordinated protocol bump. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/src/Engines/Native/nativeHelpers.ts | 17 +++++++++++-- .../src/Engines/Native/nativeInterfaces.ts | 6 ++--- .../core/src/Engines/thinNativeEngine.pure.ts | 25 ++++++++++++++++++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/dev/core/src/Engines/Native/nativeHelpers.ts b/packages/dev/core/src/Engines/Native/nativeHelpers.ts index 1b8c085976cb..9bacb6774291 100644 --- a/packages/dev/core/src/Engines/Native/nativeHelpers.ts +++ b/packages/dev/core/src/Engines/Native/nativeHelpers.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { ErrorCodes, RuntimeError } from "core/Misc/error"; +import { Logger } from "core/Misc/logger"; import { Constants } from "../constants"; import { type INative } from "./nativeInterfaces"; import { VertexBuffer } from "core/Buffers/buffer.pure"; @@ -299,6 +300,18 @@ export function getNativeStencilDepthPass(opPass: number): number { } } +const _warnedUnsupportedAlphaModes = new Set(); + +// Some alpha modes were introduced alongside newer Babylon Native features. When running against an older +// native binary the corresponding _native.Engine constant is undefined; warn once and fall back to ALPHA_ONEONE. +function _getFallbackAlphaMode(mode: number, unsupportedName: string): number { + if (!_warnedUnsupportedAlphaModes.has(mode)) { + _warnedUnsupportedAlphaModes.add(mode); + Logger.Warn(`Alpha mode ${unsupportedName} is not supported by this version of Babylon Native; falling back to ALPHA_ONEONE.`); + } + return _native.Engine.ALPHA_ONEONE; +} + export function getNativeAlphaMode(mode: number): number { switch (mode) { case Constants.ALPHA_DISABLE: @@ -316,9 +329,9 @@ export function getNativeAlphaMode(mode: number): number { case Constants.ALPHA_ONEONE: return _native.Engine.ALPHA_ONEONE; case Constants.ALPHA_ONEONE_ONEONE: - return _native.Engine.ALPHA_ONEONE_ONEONE; + return _native.Engine.ALPHA_ONEONE_ONEONE ?? _getFallbackAlphaMode(mode, "ALPHA_ONEONE_ONEONE"); case Constants.ALPHA_LAYER_ACCUMULATE: - return _native.Engine.ALPHA_LAYER_ACCUMULATE; + return _native.Engine.ALPHA_LAYER_ACCUMULATE ?? _getFallbackAlphaMode(mode, "ALPHA_LAYER_ACCUMULATE"); case Constants.ALPHA_PREMULTIPLIED: return _native.Engine.ALPHA_PREMULTIPLIED; case Constants.ALPHA_PREMULTIPLIED_PORTERDUFF: diff --git a/packages/dev/core/src/Engines/Native/nativeInterfaces.ts b/packages/dev/core/src/Engines/Native/nativeInterfaces.ts index d461b64ccc98..60f519f0abf2 100644 --- a/packages/dev/core/src/Engines/Native/nativeInterfaces.ts +++ b/packages/dev/core/src/Engines/Native/nativeInterfaces.ts @@ -125,7 +125,7 @@ export interface INativeEngine { samples: number, layer?: number ): NativeFramebuffer; - createMultiFrameBuffer( + createMultiFrameBuffer?( textures: NativeTexture[], width: number, height: number, @@ -301,8 +301,8 @@ interface INativeEngineConstructor { readonly ALPHA_MULTIPLY: number; readonly ALPHA_MAXIMIZED: number; readonly ALPHA_ONEONE: number; - readonly ALPHA_ONEONE_ONEONE: number; - readonly ALPHA_LAYER_ACCUMULATE: number; + readonly ALPHA_ONEONE_ONEONE?: number; + readonly ALPHA_LAYER_ACCUMULATE?: number; readonly ALPHA_PREMULTIPLIED: number; readonly ALPHA_PREMULTIPLIED_PORTERDUFF: number; readonly ALPHA_INTERPOLATE: number; diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index 6a5db3b0e111..b51ea8f22974 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -2552,7 +2552,30 @@ export class ThinNativeEngine extends ThinEngine { // The native engine creates one framebuffer with all the color attachments (bgfx writes to every // attachment of the bound framebuffer, so there is no drawBuffers equivalent to issue per draw). if (colorHandles.length > 0) { - rtWrapper._framebuffer = this._engine.createMultiFrameBuffer(colorHandles, width, height, generateStencilBuffer, generateDepthBuffer || generateDepthTexture, samples); + if (this._engine.createMultiFrameBuffer) { + rtWrapper._framebuffer = this._engine.createMultiFrameBuffer( + colorHandles, + width, + height, + generateStencilBuffer, + generateDepthBuffer || generateDepthTexture, + samples + ); + } else { + // Older Babylon Native binaries (predating multi render target support) do not expose createMultiFrameBuffer. + // Fall back to a single-attachment framebuffer bound to the first color target so the scene keeps rendering. + Logger.Warn( + "createMultiFrameBuffer is not supported by this version of Babylon Native; multi render targets are unavailable. Falling back to a single-attachment framebuffer bound to the first color target." + ); + rtWrapper._framebuffer = this._engine.createFrameBuffer( + colorHandles[0], + width, + height, + generateStencilBuffer, + generateDepthBuffer || generateDepthTexture, + samples + ); + } } rtWrapper._generateDepthBuffer = generateDepthBuffer || generateDepthTexture; From 7918045cc0855f8a4859d5670556dae433eec248 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 8 Jul 2026 16:19:57 -0700 Subject: [PATCH 3/6] [Native] Route reverse-Z clear depth compare through native command path clear() set depthCullingState.depthFunc = GEQUAL to mirror the WebGL engine, but the native draw path never calls applyStates() before a draw (only depth-test enable/disable is reconciled in _flushDepthTestState), so the GEQUAL comparison never reached the backend and reverse-Z had no effect. Also call setDepthFunction(GEQUAL) to encode the compare via the native command path, mirroring the WebGPU engine's clear path (setDepthFunctionToGreaterOrEqual). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/dev/core/src/Engines/thinNativeEngine.pure.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index b51ea8f22974..4ec73780caae 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -579,9 +579,14 @@ export class ThinNativeEngine extends ThinEngine { public override clear(color: Nullable, backBuffer: boolean, depth: boolean, stencil: boolean = false, stencilClearValue = 0): void { if (depth && this.useReverseDepthBuffer) { // Reverse-Z: the scene is rendered with a flipped projection (near maps to 1, far to 0), so the - // depth buffer is cleared to 0 and the comparison must accept greater values. Mirror the WebGL - // engine, which sets the depth-culling comparison to GEQUAL here and clears depth to 0. + // depth buffer is cleared to 0 and the comparison must accept greater values. The WebGL engine + // sets depthCullingState.depthFunc = GEQUAL here and relies on applyStates() running the depth + // comparison to the GL context before each draw. The native draw path does not call applyStates() + // (only depth-test enable/disable is reconciled in _flushDepthTestState()), so setting the shared + // state alone would never reach the backend. Route the comparison through the native command path + // as well -- mirroring the WebGPU engine's clear path, which calls setDepthFunctionToGreaterOrEqual(). this._depthCullingState.depthFunc = Constants.GEQUAL; + this.setDepthFunction(Constants.GEQUAL); } this._commandBufferEncoder.startEncodingCommand(_native.Engine.COMMAND_CLEAR); From c00d16602aee6f55f153843afc2668da476a3a16 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 8 Jul 2026 17:23:10 -0700 Subject: [PATCH 4/6] [Native] Recreate MRT framebuffer when attachments are swapped bgfx binds a fixed attachment set at framebuffer creation and cannot re-point an individual attachment the way GL's framebufferTexture2D can. When a multi render target attachment is replaced after creation (e.g. the OIT depth-peeling renderer swaps every attachment via MultiRenderTarget.setInternalTexture), the native framebuffer stayed bound to the original creation-time textures. Extract the framebuffer build from createMultipleRenderTarget into _createMultiRenderTargetFramebuffer and call it from a NativeRenderTargetWrapper.setTexture override so the framebuffer is rebuilt from the current attachment set on every swap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Native/nativeRenderTargetWrapper.ts | 14 +++ .../core/src/Engines/thinNativeEngine.pure.ts | 91 ++++++++++++------- 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/packages/dev/core/src/Engines/Native/nativeRenderTargetWrapper.ts b/packages/dev/core/src/Engines/Native/nativeRenderTargetWrapper.ts index 37df65b7f764..825e513564cb 100644 --- a/packages/dev/core/src/Engines/Native/nativeRenderTargetWrapper.ts +++ b/packages/dev/core/src/Engines/Native/nativeRenderTargetWrapper.ts @@ -1,4 +1,5 @@ import { type Nullable } from "../../types"; +import { type InternalTexture } from "../../Materials/Textures/internalTexture"; import { type TextureSize } from "../../Materials/Textures/textureCreationOptions"; import { RenderTargetWrapper } from "../renderTargetWrapper"; import { type NativeFramebuffer } from "./nativeInterfaces"; @@ -57,6 +58,19 @@ export class NativeRenderTargetWrapper extends RenderTargetWrapper { this._engine = engine; } + public override setTexture(texture: InternalTexture, index: number = 0, disposePrevious: boolean = true): void { + const previous = this.textures?.[index]; + super.setTexture(texture, index, disposePrevious); + + // bgfx binds a fixed attachment set when a framebuffer is created and cannot re-point an individual + // attachment the way GL's framebufferTexture2D can. When a multi render target attachment is swapped + // after creation (e.g. the OIT depth-peeling renderer replaces every attachment via + // MultiRenderTarget.setInternalTexture), recreate the whole framebuffer from the new attachment set. + if (this.isMulti && this.textures?.[index] !== previous) { + this._engine._createMultiRenderTargetFramebuffer(this); + } + } + public override dispose(disposeOnlyFramebuffers = false): void { if (this.__framebuffers) { // Releases all six per-face framebuffers (face 0 is aliased by __framebuffer, so diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index 4ec73780caae..bd9a56c763f0 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -2505,7 +2505,6 @@ export class ThinNativeEngine extends ThinEngine { const textures: InternalTexture[] = []; const attachments: number[] = []; - const colorHandles: NativeTexture[] = []; for (let i = 0; i < textureCount; i++) { const samplingMode = samplingModes[i] || Constants.TEXTURE_TRILINEAR_SAMPLINGMODE; @@ -2550,39 +2549,9 @@ export class ThinNativeEngine extends ThinEngine { texture.label = labels[i] ?? rtWrapper.label + "-Texture" + i; textures[i] = texture; - colorHandles.push(nativeTexture); this._internalTexturesCache.push(texture); } - // The native engine creates one framebuffer with all the color attachments (bgfx writes to every - // attachment of the bound framebuffer, so there is no drawBuffers equivalent to issue per draw). - if (colorHandles.length > 0) { - if (this._engine.createMultiFrameBuffer) { - rtWrapper._framebuffer = this._engine.createMultiFrameBuffer( - colorHandles, - width, - height, - generateStencilBuffer, - generateDepthBuffer || generateDepthTexture, - samples - ); - } else { - // Older Babylon Native binaries (predating multi render target support) do not expose createMultiFrameBuffer. - // Fall back to a single-attachment framebuffer bound to the first color target so the scene keeps rendering. - Logger.Warn( - "createMultiFrameBuffer is not supported by this version of Babylon Native; multi render targets are unavailable. Falling back to a single-attachment framebuffer bound to the first color target." - ); - rtWrapper._framebuffer = this._engine.createFrameBuffer( - colorHandles[0], - width, - height, - generateStencilBuffer, - generateDepthBuffer || generateDepthTexture, - samples - ); - } - } - rtWrapper._generateDepthBuffer = generateDepthBuffer || generateDepthTexture; rtWrapper._generateStencilBuffer = generateStencilBuffer; rtWrapper._samples = samples; @@ -2591,9 +2560,69 @@ export class ThinNativeEngine extends ThinEngine { rtWrapper.setTextures(textures); rtWrapper.setLayerAndFaceIndices(layerIndex, faceIndex); + // The native engine creates one framebuffer with all the color attachments (bgfx writes to every + // attachment of the bound framebuffer, so there is no drawBuffers equivalent to issue per draw). + this._createMultiRenderTargetFramebuffer(rtWrapper); + return rtWrapper; } + /** + * Creates (or recreates) the native framebuffer of a multi render target from the color attachment + * textures currently held by the wrapper. bgfx binds a fixed attachment set when a framebuffer is + * created and cannot re-point an individual attachment the way GL's framebufferTexture2D can, so the + * whole framebuffer has to be recreated whenever an attachment is swapped after creation (e.g. the OIT + * depth-peeling renderer replaces every attachment via MultiRenderTarget.setInternalTexture). + * @param rtWrapper The multi render target wrapper to build the framebuffer for. + * @internal + */ + public _createMultiRenderTargetFramebuffer(rtWrapper: NativeRenderTargetWrapper): void { + const textures = rtWrapper.textures; + if (!textures) { + return; + } + + const colorHandles: NativeTexture[] = []; + for (const texture of textures) { + const handle = texture?._hardwareTexture?.underlyingResource; + if (handle) { + colorHandles.push(handle); + } + } + + if (colorHandles.length === 0) { + return; + } + + const width = rtWrapper.width; + const height = rtWrapper.height; + + if (this._engine.createMultiFrameBuffer) { + rtWrapper._framebuffer = this._engine.createMultiFrameBuffer( + colorHandles, + width, + height, + rtWrapper._generateStencilBuffer, + rtWrapper._generateDepthBuffer, + rtWrapper._samples + ); + } else { + // Older Babylon Native binaries (predating multi render target support) do not expose createMultiFrameBuffer. + // Fall back to a single-attachment framebuffer bound to the first color target so the scene keeps rendering. + Logger.Warn( + "createMultiFrameBuffer is not supported by this version of Babylon Native; multi render targets are unavailable. Falling back to a single-attachment framebuffer bound to the first color target." + ); + rtWrapper._framebuffer = this._engine.createFrameBuffer( + colorHandles[0], + width, + height, + rtWrapper._generateStencilBuffer, + rtWrapper._generateDepthBuffer, + rtWrapper._samples + ); + } + } + public override generateMipMapsForCubemap(_texture: InternalTexture, _unbind = true): void { // The WebGL path rebinds gl.TEXTURE_CUBE_MAP and calls gl.generateMipmap; both deref _gl, which is // null on Native. bgfx auto-generates the mip chain when a render target texture created with mips is From 6a48b30804a74467902185c1e8950e67c7d2d6c9 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 8 Jul 2026 17:54:48 -0700 Subject: [PATCH 5/6] [Native] Only build MRT framebuffer once all color attachments are present bgfx framebuffers use a dense, ordered attachment list. Building _createMultiRenderTargetFramebuffer from a partial attachment set (attachments deferred via dontCreateTextures / targetTypes[i] === -1, or filled out of order) would compress the attachment indices and produce a framebuffer that does not match the MRT layout. Guard the build so it only runs when the collected color handles match the expected attachment count (rtWrapper._attachments.length), which also guarantees contiguous, in-order handles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/dev/core/src/Engines/thinNativeEngine.pure.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index bd9a56c763f0..5a7b1aab803d 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -2590,7 +2590,13 @@ export class ThinNativeEngine extends ThinEngine { } } - if (colorHandles.length === 0) { + // bgfx framebuffers use a dense, ordered attachment list, so only (re)build once every expected color + // attachment is present. Building from a partial set (e.g. attachments deferred via dontCreateTextures / + // targetTypes[i] === -1, or filled out of order) would compress the attachment indices -- attachment 2 + // would become attachment 1, etc. -- and produce a framebuffer that does not match the MRT layout. The + // count match guarantees the collected handles are contiguous and in attachment order. + const expectedCount = rtWrapper._attachments ? rtWrapper._attachments.length : colorHandles.length; + if (colorHandles.length === 0 || colorHandles.length !== expectedCount) { return; } From ddc75781c15986d865140ec10d615d0535fd2dd3 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 9 Jul 2026 08:04:04 -0700 Subject: [PATCH 6/6] [Native] Reissue MRT attachment MSAA + warn-once on FB fallback updateMultipleRenderTargetTextureSampleCount was a no-op that only recorded _samples, so RenderTargetWrapper.setSamples() silently did nothing for MRTs on Native (bgfx couples MSAA to texture creation flags). Mirror updateRenderTargetTextureSampleCount: reissue each non-external color attachment's bgfx handle with the new sample count via initializeTexture, update texture.samples, then recreate the framebuffer so it points at the rotated handles. Also pass limit=1 to the createMultiFrameBuffer-fallback Logger.Warn so it warns once instead of spamming when the framebuffer is rebuilt during attachment setup/swaps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/src/Engines/thinNativeEngine.pure.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index 5a7b1aab803d..fd6aafb89296 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -2615,8 +2615,10 @@ export class ThinNativeEngine extends ThinEngine { } else { // Older Babylon Native binaries (predating multi render target support) do not expose createMultiFrameBuffer. // Fall back to a single-attachment framebuffer bound to the first color target so the scene keeps rendering. + // Warn once (limit 1): the framebuffer can be rebuilt many times during attachment setup/swaps. Logger.Warn( - "createMultiFrameBuffer is not supported by this version of Babylon Native; multi render targets are unavailable. Falling back to a single-attachment framebuffer bound to the first color target." + "createMultiFrameBuffer is not supported by this version of Babylon Native; multi render targets are unavailable. Falling back to a single-attachment framebuffer bound to the first color target.", + 1 ); rtWrapper._framebuffer = this._engine.createFrameBuffer( colorHandles[0], @@ -2676,10 +2678,48 @@ export class ThinNativeEngine extends ThinEngine { } public override updateMultipleRenderTargetTextureSampleCount(rtWrapper: Nullable, samples: number, _initializeBuffers = true): number { - // Native MSAA is configured at texture creation; just record the requested sample count. - if (rtWrapper) { - (rtWrapper as NativeRenderTargetWrapper)._samples = samples; + if (!rtWrapper || rtWrapper.samples === samples) { + return samples; + } + + const textures = rtWrapper.textures; + if (!textures) { + return rtWrapper.samples; } + + const nativeRTWrapper = rtWrapper as NativeRenderTargetWrapper; + + // bgfx couples MSAA to the texture creation flags (see updateRenderTargetTextureSampleCount), so + // changing the sample count after the fact requires reissuing every color attachment's underlying + // bgfx handle with the new MSAA flag. initializeTexture disposes the old handle and allocates a fresh + // one while preserving the InternalTexture / Graphics::Texture identity; only the internal bgfx handle + // rotates. Afterwards the framebuffer is recreated so its attachment list refers to the new handles. + for (const texture of textures) { + // Wrapped (External-source) textures own an opaque external handle (format=-1); reinitializing + // would destroy it and getNativeTextureFormat would throw, so leave those attachments untouched. + if (!texture?._hardwareTexture || texture.source === InternalTextureSource.External) { + continue; + } + + const nativeTexture = texture._hardwareTexture.underlyingResource; + // See the bgfx-msaa-mips workaround in updateRenderTargetTextureSampleCount (BabylonNative#1714). + const hasMips = samples > 1 ? false : texture.generateMipMaps; + this._engine.initializeTexture( + nativeTexture, + texture.baseWidth, + texture.baseHeight, + hasMips, + getNativeTextureFormat(texture.format, texture.type), + /*renderTarget*/ true, + texture._useSRGBBuffer, + samples + ); + texture.samples = samples; + } + + nativeRTWrapper._samples = samples; + this._createMultiRenderTargetFramebuffer(nativeRTWrapper); + return samples; }