From 3381c852aabec57e1a8c65dbc446a6fd886e3698 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 10 Jun 2026 21:24:56 -0700 Subject: [PATCH 1/4] [Native] Add cube render target support to the native engine NativeEngine had no createRenderTargetCubeTexture / per-face bindFramebuffer override, so ReflectionProbe and point-light cube shadows fell through to the WebGL path and dereferenced the null _gl context (TEXTURE_CUBE_MAP undefined). - createRenderTargetCubeTexture: native cube color texture + one framebuffer per face (the native side binds the matching cube layer). - bindFramebuffer: bind the per-face framebuffer for cube render targets. - generateMipMapsForCubemap: no-op on Native (bgfx auto-generates the mip chain on render-target resolve, like 2D RTTs). - NativeRenderTargetWrapper: track per-face framebuffers, release on dispose. - nativeInterfaces: thread cube/layer params through initializeTexture and createFrameBuffer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Engines/Native/nativeInterfaces.ts | 15 +++- .../Native/nativeRenderTargetWrapper.ts | 26 +++++- .../core/src/Engines/thinNativeEngine.pure.ts | 81 +++++++++++++++++-- 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/packages/dev/core/src/Engines/Native/nativeInterfaces.ts b/packages/dev/core/src/Engines/Native/nativeInterfaces.ts index ff6b7a8e6562..2eec40c1ef12 100644 --- a/packages/dev/core/src/Engines/Native/nativeInterfaces.ts +++ b/packages/dev/core/src/Engines/Native/nativeInterfaces.ts @@ -61,7 +61,17 @@ export interface INativeEngine { getAttributes(shaderProgram: NativeProgram, attributeNames: string[]): number[]; createTexture(): NativeTexture; - initializeTexture(texture: NativeTexture, width: number, height: number, hasMips: boolean, format: number, renderTarget: boolean, srgb: boolean, samples: number): void; + initializeTexture( + texture: NativeTexture, + width: number, + height: number, + hasMips: boolean, + format: number, + renderTarget: boolean, + srgb: boolean, + samples: number, + isCube?: boolean + ): void; loadTexture(texture: NativeTexture, data: ArrayBufferView, generateMips: boolean, invertY: boolean, srgb: boolean, onSuccess: () => void, onError: () => void): void; loadRawTexture(texture: NativeTexture, data: ArrayBufferView, width: number, height: number, format: number, generateMips: boolean, invertY: boolean): void; loadRawTexture2DArray( @@ -100,7 +110,8 @@ export interface INativeEngine { height: number, generateStencilBuffer: boolean, generateDepthBuffer: boolean, - samples: number + samples: number, + layer?: number ): NativeFramebuffer; getRenderWidth(): number; diff --git a/packages/dev/core/src/Engines/Native/nativeRenderTargetWrapper.ts b/packages/dev/core/src/Engines/Native/nativeRenderTargetWrapper.ts index f1ba367132a5..37df65b7f764 100644 --- a/packages/dev/core/src/Engines/Native/nativeRenderTargetWrapper.ts +++ b/packages/dev/core/src/Engines/Native/nativeRenderTargetWrapper.ts @@ -11,6 +11,9 @@ export class NativeRenderTargetWrapper extends RenderTargetWrapper { private __framebuffer: Nullable = null; // eslint-disable-next-line @typescript-eslint/naming-convention private __framebufferDepthStencil: Nullable = null; + // Per-face framebuffers for cube render targets (index = cube face 0..5). + // eslint-disable-next-line @typescript-eslint/naming-convention + private __framebuffers: Nullable = null; public get _framebuffer(): Nullable { return this.__framebuffer; @@ -23,6 +26,21 @@ export class NativeRenderTargetWrapper extends RenderTargetWrapper { this.__framebuffer = framebuffer; } + public get _framebuffers(): Nullable { + return this.__framebuffers; + } + + public set _framebuffers(framebuffers: Nullable) { + if (this.__framebuffers) { + for (const framebuffer of this.__framebuffers) { + this._engine._releaseFramebufferObjects(framebuffer); + } + } + this.__framebuffers = framebuffers; + // Keep _framebuffer pointing at face 0 so single-target code paths still work. + this.__framebuffer = framebuffers ? framebuffers[0] : null; + } + public get _framebufferDepthStencil(): Nullable { return this.__framebufferDepthStencil; } @@ -40,7 +58,13 @@ export class NativeRenderTargetWrapper extends RenderTargetWrapper { } public override dispose(disposeOnlyFramebuffers = false): void { - this._framebuffer = null; + if (this.__framebuffers) { + // Releases all six per-face framebuffers (face 0 is aliased by __framebuffer, so + // clear that alias here without releasing it again). + this._framebuffers = null; + } else { + this._framebuffer = null; + } this._framebufferDepthStencil = null; super.dispose(disposeOnlyFramebuffers); diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index 97f3cda2df0b..093058f3328f 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -2345,6 +2345,76 @@ export class ThinNativeEngine extends ThinEngine { return rtWrapper; } + public override createRenderTargetCubeTexture(size: number, options?: RenderTargetCreationOptions): RenderTargetWrapper { + const rtWrapper = this._createHardwareRenderTargetWrapper(false, true, size) as NativeRenderTargetWrapper; + + let generateDepthBuffer = true; + let generateStencilBuffer = false; + let generateMipMaps = false; + let type = Constants.TEXTURETYPE_UNSIGNED_BYTE; + let samplingMode = Constants.TEXTURE_TRILINEAR_SAMPLINGMODE; + let format = Constants.TEXTUREFORMAT_RGBA; + let samples = 1; + if (options !== undefined && typeof options === "object") { + generateDepthBuffer = options.generateDepthBuffer ?? true; + generateStencilBuffer = !!options.generateStencilBuffer; + generateMipMaps = !!options.generateMipMaps; + type = options.type ?? Constants.TEXTURETYPE_UNSIGNED_BYTE; + samplingMode = options.samplingMode ?? Constants.TEXTURE_TRILINEAR_SAMPLINGMODE; + format = options.format ?? Constants.TEXTUREFORMAT_RGBA; + samples = options.samples ?? 1; + } + + if (type === Constants.TEXTURETYPE_FLOAT && !this._caps.textureFloat) { + type = Constants.TEXTURETYPE_UNSIGNED_BYTE; + Logger.Warn("Float textures are not supported. Type forced to TEXTURETYPE_UNSIGNED_BYTE"); + } + + const texture = new InternalTexture(this, InternalTextureSource.RenderTarget); + texture.isCube = true; + texture.baseWidth = size; + texture.baseHeight = size; + texture.width = size; + texture.height = size; + texture.isReady = true; + texture.samples = samples; + texture.generateMipMaps = generateMipMaps; + texture.samplingMode = samplingMode; + texture.type = type; + texture.format = format; + + const nativeTexture = texture._hardwareTexture!.underlyingResource; + const nativeTextureFormat = getNativeTextureFormat(format, type); + // See the createRenderTargetTexture MSAA/mips note: avoid the mips + samples combo on bgfx. + const hasMips = samples > 1 ? false : generateMipMaps; + this._engine.initializeTexture(nativeTexture, size, size, hasMips, nativeTextureFormat, /*renderTarget*/ true, /*srgb*/ false, samples, /*isCube*/ true); + this._setTextureSampling(nativeTexture, getNativeSamplingMode(samplingMode)); + + // The native engine cannot render to all six faces through one framebuffer, so create one + // framebuffer per face (the C++ side binds the matching cube layer); bindFramebuffer(faceIndex) + // then selects the right one. + const framebuffers: NativeFramebuffer[] = []; + for (let face = 0; face < 6; face++) { + framebuffers.push(this._engine.createFrameBuffer(nativeTexture, size, size, generateStencilBuffer, generateDepthBuffer, samples, face)); + } + + rtWrapper._framebuffers = framebuffers; + rtWrapper._generateDepthBuffer = generateDepthBuffer; + rtWrapper._generateStencilBuffer = generateStencilBuffer; + rtWrapper._samples = samples; + + rtWrapper.setTextures(texture); + + 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 + // resolved (the same way 2D RTTs get their mips here -- unBindFramebuffer issues no explicit mipgen), + // so this is a no-op on Native. + } + public override updateRenderTargetTextureSampleCount(rtWrapper: RenderTargetWrapper, samples: number): number { if (rtWrapper.samples === samples) { return samples; @@ -2426,15 +2496,16 @@ export class ThinNativeEngine extends ThinEngine { this._currentRenderTarget = texture; - if (faceIndex) { - throw new Error("Cuboid frame buffers are not yet supported in NativeEngine."); - } - if (requiredWidth || requiredHeight) { throw new Error("Required width/height for frame buffers not yet supported in NativeEngine."); } - if (nativeRTWrapper._framebufferDepthStencil) { + if (nativeRTWrapper._framebuffers) { + // Cube render target: bind the framebuffer for the requested face. + this._bindUnboundFramebuffer(nativeRTWrapper._framebuffers[faceIndex ?? 0]); + } else if (faceIndex) { + throw new Error("Cuboid frame buffers are not yet supported in NativeEngine."); + } else if (nativeRTWrapper._framebufferDepthStencil) { this._bindUnboundFramebuffer(nativeRTWrapper._framebufferDepthStencil); } else { this._bindUnboundFramebuffer(nativeRTWrapper._framebuffer); From 3e4dfb0868b63f1f42e58620d1402f099a421230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Wed, 17 Jun 2026 15:18:57 -0700 Subject: [PATCH 2/4] Native: bump PROTOCOL_VERSION to 10 for cube render target API Addresses review: cube RT adds isCube/layer params that old native silently ignores (2D RT with aliased faces, no error). Bumping the protocol version makes any JS<->Native version skew fail loudly at engine init via the existing strict-equality check, instead of rendering incorrectly. Pairs with the native-side bump in BabylonJS/BabylonNative#1750 (landed together with the babylonjs pin bump). --- packages/dev/core/src/Engines/thinNativeEngine.pure.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index 093058f3328f..3ace7c4002f1 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -211,7 +211,7 @@ const remappedAttributesNames: string[] = []; /** @internal */ export class ThinNativeEngine extends ThinEngine { // This must match the protocol version in NativeEngine.cpp - private static readonly PROTOCOL_VERSION = 9; + private static readonly PROTOCOL_VERSION = 10; /** @internal */ public static _createNativeDataStream(): NativeDataStream { From d32b4889a0b81b92212eb40afce650f241bf9cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Wed, 17 Jun 2026 15:37:03 -0700 Subject: [PATCH 3/4] [Native] Cube RTT: match _createInternalTexture conventions + cube-aware MSAA update Address Copilot reviewer feedback on createRenderTargetCubeTexture: - apply float/half-float linear-filtering samplingMode fallback (NEAREST) - propagate options.label to the InternalTexture - push the hand-built texture to _internalTexturesCache for lifecycle tracking - make updateRenderTargetTextureSampleCount cube-aware (reinit with isCube + recreate all six per-face framebuffers) so changing samples after creation no longer breaks bindFramebuffer(faceIndex) --- .../core/src/Engines/thinNativeEngine.pure.ts | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index 3ace7c4002f1..30e040277dc9 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -2355,6 +2355,7 @@ export class ThinNativeEngine extends ThinEngine { let samplingMode = Constants.TEXTURE_TRILINEAR_SAMPLINGMODE; let format = Constants.TEXTUREFORMAT_RGBA; let samples = 1; + let label: string | undefined; if (options !== undefined && typeof options === "object") { generateDepthBuffer = options.generateDepthBuffer ?? true; generateStencilBuffer = !!options.generateStencilBuffer; @@ -2363,8 +2364,16 @@ export class ThinNativeEngine extends ThinEngine { samplingMode = options.samplingMode ?? Constants.TEXTURE_TRILINEAR_SAMPLINGMODE; format = options.format ?? Constants.TEXTUREFORMAT_RGBA; samples = options.samples ?? 1; + label = options.label; } + // Match _createInternalTexture: float/half-float RTTs that the platform can't linearly filter fall + // back to NEAREST so the cube RTT never carries an unsupported sampling mode. + if (type === Constants.TEXTURETYPE_FLOAT && !this._caps.textureFloatLinearFiltering) { + samplingMode = Constants.TEXTURE_NEAREST_SAMPLINGMODE; + } else if (type === Constants.TEXTURETYPE_HALF_FLOAT && !this._caps.textureHalfFloatLinearFiltering) { + samplingMode = Constants.TEXTURE_NEAREST_SAMPLINGMODE; + } if (type === Constants.TEXTURETYPE_FLOAT && !this._caps.textureFloat) { type = Constants.TEXTURETYPE_UNSIGNED_BYTE; Logger.Warn("Float textures are not supported. Type forced to TEXTURETYPE_UNSIGNED_BYTE"); @@ -2382,6 +2391,7 @@ export class ThinNativeEngine extends ThinEngine { texture.samplingMode = samplingMode; texture.type = type; texture.format = format; + texture.label = label; const nativeTexture = texture._hardwareTexture!.underlyingResource; const nativeTextureFormat = getNativeTextureFormat(format, type); @@ -2405,6 +2415,10 @@ export class ThinNativeEngine extends ThinEngine { rtWrapper.setTextures(texture); + // Track the hand-built cube RTT texture the same way _createInternalTexture tracks 2D textures so it + // participates in engine-wide lifecycle management (dispose iteration, context rebuild, stats). + this._internalTexturesCache.push(texture); + return rtWrapper; } @@ -2460,19 +2474,51 @@ export class ThinNativeEngine extends ThinEngine { // underlying bgfx resource has 1 mip level. Remove this guard once a fixed bgfx is in stable BN npm. const hasMips = samples > 1 ? false : texture.generateMipMaps; const nativeTextureFormat = getNativeTextureFormat(texture.format, texture.type); - this._engine.initializeTexture(nativeTexture, texture.baseWidth, texture.baseHeight, hasMips, nativeTextureFormat, /*renderTarget*/ true, texture._useSRGBBuffer, samples); - - // NativeRenderTargetWrapper._framebuffer setter releases the old framebuffer before assigning, - // so no manual _releaseFramebufferObjects call is needed (and would double-delete the handle). - nativeRTWrapper._framebuffer = this._engine.createFrameBuffer( + const isCube = texture.isCube; + this._engine.initializeTexture( nativeTexture, texture.baseWidth, texture.baseHeight, - rtWrapper._generateStencilBuffer, - rtWrapper._generateDepthBuffer, - samples + hasMips, + nativeTextureFormat, + /*renderTarget*/ true, + texture._useSRGBBuffer, + samples, + isCube ); + if (isCube) { + // Cube RTTs render through one framebuffer per face (see createRenderTargetCubeTexture). The + // underlying bgfx handle was just rotated, so recreate all six attachments; the _framebuffers + // setter releases the stale ones and keeps _framebuffer aliased to face 0 for single-target paths. + const framebuffers: NativeFramebuffer[] = []; + for (let face = 0; face < 6; face++) { + framebuffers.push( + this._engine.createFrameBuffer( + nativeTexture, + texture.baseWidth, + texture.baseHeight, + rtWrapper._generateStencilBuffer, + rtWrapper._generateDepthBuffer, + samples, + face + ) + ); + } + nativeRTWrapper._framebuffers = framebuffers; + } else { + // NativeRenderTargetWrapper._framebuffer setter releases the old framebuffer before assigning, + // so no manual _releaseFramebufferObjects call is needed (and would double-delete the handle). + nativeRTWrapper._framebuffer = this._engine.createFrameBuffer( + nativeTexture, + texture.baseWidth, + texture.baseHeight, + rtWrapper._generateStencilBuffer, + rtWrapper._generateDepthBuffer, + samples + ); + } + rtWrapper._samples = samples; texture.samples = samples; return samples; From 2d21f28d76aba85a0de71e931bb2866ebea31306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Wed, 17 Jun 2026 16:40:07 -0700 Subject: [PATCH 4/4] [Native] Revert PROTOCOL_VERSION bump to 9 to keep native CI green The required Native tests check runs the PR JS against the BabylonNative Nightly artifact (native PROTOCOL_VERSION = 9 from BN master), so bumping the JS side to 10 made new NativeEngine() throw a protocol-version mismatch and crash every native test. The version gate will land later as a coordinated atomic bump (BJS JS + BabylonNative native + babylonjs pin together). --- packages/dev/core/src/Engines/thinNativeEngine.pure.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts index 30e040277dc9..898224c963ff 100644 --- a/packages/dev/core/src/Engines/thinNativeEngine.pure.ts +++ b/packages/dev/core/src/Engines/thinNativeEngine.pure.ts @@ -211,7 +211,7 @@ const remappedAttributesNames: string[] = []; /** @internal */ export class ThinNativeEngine extends ThinEngine { // This must match the protocol version in NativeEngine.cpp - private static readonly PROTOCOL_VERSION = 10; + private static readonly PROTOCOL_VERSION = 9; /** @internal */ public static _createNativeDataStream(): NativeDataStream {