From c674353a610ed36e86e62366422c888b948a39c6 Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Mon, 17 Nov 2025 07:35:05 -0500 Subject: [PATCH 01/12] Enable shader and pipeline caching by default --- modules/core/src/adapter/device.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/core/src/adapter/device.ts b/modules/core/src/adapter/device.ts index 9c423a9ae4..c5d3119093 100644 --- a/modules/core/src/adapter/device.ts +++ b/modules/core/src/adapter/device.ts @@ -361,8 +361,8 @@ export abstract class Device { // Experimental _reuseDevices: false, _requestMaxLimits: true, - _cacheShaders: false, - _cachePipelines: false, + _cacheShaders: true, + _cachePipelines: true, _cacheDestroyPolicy: 'unused', // TODO - Change these after confirming things work as expected _initializeFeatures: true, From 5a0c99b8d618a8732082cc75ce3324eaf586687d Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:39:13 -0500 Subject: [PATCH 02/12] Fix pipeline uniforms when caching enabled --- modules/engine/src/model/model.ts | 1 + modules/engine/test/lib/model.spec.ts | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/modules/engine/src/model/model.ts b/modules/engine/src/model/model.ts index 84e6805ffb..3892e2260e 100644 --- a/modules/engine/src/model/model.ts +++ b/modules/engine/src/model/model.ts @@ -426,6 +426,7 @@ export class Model { // Any caching needs to be done inside the pipeline functions // TODO this is a busy initialized check for all bindings every frame + this.pipeline.uniforms = {...this.props.uniforms}; const syncBindings = this._getBindings(); this.pipeline.setBindings(syncBindings, { disableWarnings: this.props.disableWarnings diff --git a/modules/engine/test/lib/model.spec.ts b/modules/engine/test/lib/model.spec.ts index d9eab3c8eb..8ff69c3581 100644 --- a/modules/engine/test/lib/model.spec.ts +++ b/modules/engine/test/lib/model.spec.ts @@ -265,20 +265,18 @@ test('Model#pipeline caching', async t => { const renderPass = webglDevice.beginRenderPass({clearColor: [0, 0, 0, 0]}); - const uniforms: Record = {}; - model1.draw(renderPass); - t.deepEqual(uniforms, {x: 0.5}, 'Pipeline uniforms set'); + t.deepEqual(model1.pipeline.uniforms, {x: 0.5}, 'Pipeline uniforms set'); model2.draw(renderPass); - t.deepEqual(uniforms, {x: -0.5}, 'Pipeline uniforms set'); + t.deepEqual(model2.pipeline.uniforms, {x: -0.5}, 'Pipeline uniforms set'); model2.setBufferLayout([{name: 'a', format: 'float32x3'}]); model2.predraw(); // Forces a pipeline update t.ok(model1.pipeline !== model2.pipeline, 'Pipeline updated'); model2.draw(renderPass); - t.deepEqual(uniforms, {x: -0.5}, 'Pipeline uniforms set'); + t.deepEqual(model2.pipeline.uniforms, {x: -0.5}, 'Pipeline uniforms set'); renderPass.destroy(); From 60be9c766db516e1c1221420b3adc2dead0fc864 Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:57:13 -0500 Subject: [PATCH 03/12] Initialize render pipeline uniforms --- modules/core/src/adapter/resources/render-pipeline.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/core/src/adapter/resources/render-pipeline.ts b/modules/core/src/adapter/resources/render-pipeline.ts index 3a4841cf26..6ebaee9099 100644 --- a/modules/core/src/adapter/resources/render-pipeline.ts +++ b/modules/core/src/adapter/resources/render-pipeline.ts @@ -74,6 +74,10 @@ export abstract class RenderPipeline extends Resource { shaderLayout: ShaderLayout; /** Buffer map describing buffer interleaving etc */ readonly bufferLayout: BufferLayout[]; + /** WebGL-only uniforms map stored for shared pipelines */ + uniforms: Record; + /** Bindings map stored for shared pipelines */ + bindings: Record; /** The linking status of the pipeline. 'pending' if linking is asynchronous, and on production */ linkStatus: 'pending' | 'success' | 'error' = 'pending'; /** The hash of the pipeline */ @@ -83,6 +87,8 @@ export abstract class RenderPipeline extends Resource { super(device, props, RenderPipeline.defaultProps); this.shaderLayout = this.props.shaderLayout!; this.bufferLayout = this.props.bufferLayout || []; + this.uniforms = {...this.props.uniforms}; + this.bindings = {...this.props.bindings}; } /** Set bindings (stored on pipeline and set before each call) */ From 54b3d1555c7bfe15416f8afa889e74a2b49be397 Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:54:23 -0500 Subject: [PATCH 04/12] Make render pipelines stateless for bindings --- .../src/adapter/resources/render-pipeline.ts | 25 +---- modules/engine/src/model/model.ts | 12 +-- modules/engine/test/lib/model.spec.ts | 9 +- .../resources/null-render-pipeline.ts | 17 +--- .../resources/webgl-render-pipeline.ts | 94 +++---------------- .../adapter/resources/webgpu-render-pass.ts | 7 +- .../resources/webgpu-render-pipeline.ts | 31 +----- 7 files changed, 39 insertions(+), 156 deletions(-) diff --git a/modules/core/src/adapter/resources/render-pipeline.ts b/modules/core/src/adapter/resources/render-pipeline.ts index 6ebaee9099..ac6e069678 100644 --- a/modules/core/src/adapter/resources/render-pipeline.ts +++ b/modules/core/src/adapter/resources/render-pipeline.ts @@ -3,7 +3,6 @@ // Copyright (c) vis.gl contributors import type {Device} from '../device'; -import type {UniformValue} from '../types/uniforms'; import type {PrimitiveTopology, RenderPipelineParameters} from '../types/parameters'; import type {ShaderLayout, Binding} from '../types/shader-layout'; import type {BufferLayout} from '../types/buffer-layout'; @@ -52,11 +51,8 @@ export type RenderPipelineProps = ResourceProps & { parameters?: RenderPipelineParameters; // Dynamic bindings (TODO - pipelines should be immutable, move to RenderPass) - /** Buffers, Textures, Samplers for the shader bindings */ bindings?: Record; - /** @deprecated uniforms (WebGL only) */ - uniforms?: Record; }; /** @@ -74,10 +70,6 @@ export abstract class RenderPipeline extends Resource { shaderLayout: ShaderLayout; /** Buffer map describing buffer interleaving etc */ readonly bufferLayout: BufferLayout[]; - /** WebGL-only uniforms map stored for shared pipelines */ - uniforms: Record; - /** Bindings map stored for shared pipelines */ - bindings: Record; /** The linking status of the pipeline. 'pending' if linking is asynchronous, and on production */ linkStatus: 'pending' | 'success' | 'error' = 'pending'; /** The hash of the pipeline */ @@ -87,16 +79,8 @@ export abstract class RenderPipeline extends Resource { super(device, props, RenderPipeline.defaultProps); this.shaderLayout = this.props.shaderLayout!; this.bufferLayout = this.props.bufferLayout || []; - this.uniforms = {...this.props.uniforms}; - this.bindings = {...this.props.bindings}; } - /** Set bindings (stored on pipeline and set before each call) */ - abstract setBindings( - bindings: Record, - options?: {disableWarnings?: boolean} - ): void; - /** Draw call. Returns false if the draw call was aborted (due to resources still initializing) */ abstract draw(options: { /** Render pass to draw into (targeting screen or framebuffer) */ @@ -124,6 +108,10 @@ export abstract class RenderPipeline extends Resource { baseVertex?: number; /** Transform feedback. WebGL only. */ transformFeedback?: TransformFeedback; + /** Bindings applied for this draw (textures, samplers, uniform buffers) */ + bindings?: Record; + /** WebGL-only uniforms */ + uniforms?: Record; }): boolean; static override defaultProps: Required = { @@ -144,9 +132,6 @@ export abstract class RenderPipeline extends Resource { colorAttachmentFormats: undefined!, depthStencilAttachmentFormat: undefined!, - parameters: {}, - - bindings: {}, - uniforms: {} + parameters: {} }; } diff --git a/modules/engine/src/model/model.ts b/modules/engine/src/model/model.ts index 3892e2260e..d2fce1c11f 100644 --- a/modules/engine/src/model/model.ts +++ b/modules/engine/src/model/model.ts @@ -132,6 +132,8 @@ export class Model { indexBuffer: null, attributes: {}, constantAttributes: {}, + bindings: {}, + uniforms: {}, varyings: [], isInstanced: undefined!, @@ -422,15 +424,7 @@ export class Model { // Application can call Model.predraw() to avoid this. this.pipeline = this._updatePipeline(); - // Set pipeline state, we may be sharing a pipeline so we need to set all state on every draw - // Any caching needs to be done inside the pipeline functions - // TODO this is a busy initialized check for all bindings every frame - - this.pipeline.uniforms = {...this.props.uniforms}; const syncBindings = this._getBindings(); - this.pipeline.setBindings(syncBindings, { - disableWarnings: this.props.disableWarnings - }); const {indexBuffer} = this.vertexArray; const indexCount = indexBuffer @@ -445,6 +439,8 @@ export class Model { instanceCount: this.instanceCount, indexCount, transformFeedback: this.transformFeedback || undefined, + bindings: syncBindings, + uniforms: this.props.uniforms, // WebGL shares underlying cached pipelines even for models that have different parameters and topology, // so we must provide our unique parameters to each draw // (In WebGPU most parameters are encoded in the pipeline and cannot be changed per draw call) diff --git a/modules/engine/test/lib/model.spec.ts b/modules/engine/test/lib/model.spec.ts index 8ff69c3581..5287d844f2 100644 --- a/modules/engine/test/lib/model.spec.ts +++ b/modules/engine/test/lib/model.spec.ts @@ -265,18 +265,15 @@ test('Model#pipeline caching', async t => { const renderPass = webglDevice.beginRenderPass({clearColor: [0, 0, 0, 0]}); - model1.draw(renderPass); - t.deepEqual(model1.pipeline.uniforms, {x: 0.5}, 'Pipeline uniforms set'); + t.ok(model1.draw(renderPass), 'First model draw succeeded'); - model2.draw(renderPass); - t.deepEqual(model2.pipeline.uniforms, {x: -0.5}, 'Pipeline uniforms set'); + t.ok(model2.draw(renderPass), 'Second model draw succeeded'); model2.setBufferLayout([{name: 'a', format: 'float32x3'}]); model2.predraw(); // Forces a pipeline update t.ok(model1.pipeline !== model2.pipeline, 'Pipeline updated'); - model2.draw(renderPass); - t.deepEqual(model2.pipeline.uniforms, {x: -0.5}, 'Pipeline uniforms set'); + t.ok(model2.draw(renderPass), 'Pipeline updates still draw'); renderPass.destroy(); diff --git a/modules/test-utils/src/null-device/resources/null-render-pipeline.ts b/modules/test-utils/src/null-device/resources/null-render-pipeline.ts index 300c013b2a..b65c3d852e 100644 --- a/modules/test-utils/src/null-device/resources/null-render-pipeline.ts +++ b/modules/test-utils/src/null-device/resources/null-render-pipeline.ts @@ -2,13 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import type { - UniformValue, - RenderPipelineProps, - Binding, - RenderPass, - VertexArray -} from '@luma.gl/core'; +import type {RenderPipelineProps, Binding, RenderPass, VertexArray} from '@luma.gl/core'; import {RenderPipeline} from '@luma.gl/core'; import type {NullDevice} from '../null-device'; @@ -22,9 +16,6 @@ export class NullRenderPipeline extends RenderPipeline { vs: NullShader; fs: NullShader; - uniforms: Record = {}; - bindings: Record = {}; - constructor(device: NullDevice, props: RenderPipelineProps) { super(device, props); this.device = device; @@ -39,15 +30,13 @@ export class NullRenderPipeline extends RenderPipeline { }; } - setBindings(bindings: Record): void { - Object.assign(this.bindings, bindings); - } - draw(options: { renderPass: RenderPass; vertexArray: VertexArray; vertexCount?: number; instanceCount?: number; + bindings?: Record; + uniforms?: Record; }): boolean { const {renderPass, vertexArray} = options; vertexArray.bindBeforeRender(renderPass); diff --git a/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts b/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts index 07eba2ab6c..0d4d2a6dba 100644 --- a/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts +++ b/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts @@ -46,10 +46,6 @@ export class WEBGLRenderPipeline extends RenderPipeline { /** The layout extracted from shader by WebGL introspection APIs */ introspectedLayout: ShaderLayout; - /** Uniforms set on this model */ - uniforms: Record = {}; - /** Bindings set on this model */ - bindings: Record = {}; /** WebGL varyings */ varyings: string[] | null = null; @@ -104,68 +100,6 @@ export class WEBGLRenderPipeline extends RenderPipeline { } } - /** - * Bindings include: textures, samplers and uniform buffers - * @todo needed for portable model - */ - setBindings(bindings: Record, options?: {disableWarnings?: boolean}): void { - // if (log.priority >= 2) { - // checkUniformValues(uniforms, this.id, this._uniformSetters); - // } - - for (const [name, value] of Object.entries(bindings)) { - // Accept both `xyz` and `xyzUniforms` as valid names for `xyzUniforms` uniform block - // This convention allows shaders to name uniform blocks as `uniform appUniforms {} app;` - // and reference them as `app` from both GLSL and JS. - // TODO - this is rather hacky - we could also remap the name directly in the shader layout. - const binding = - this.shaderLayout.bindings.find(binding_ => binding_.name === name) || - this.shaderLayout.bindings.find(binding_ => binding_.name === `${name}Uniforms`); - - if (!binding) { - const validBindings = this.shaderLayout.bindings - .map(binding_ => `"${binding_.name}"`) - .join(', '); - if (!options?.disableWarnings) { - log.warn( - `No binding "${name}" in render pipeline "${this.id}", expected one of ${validBindings}`, - value - )(); - } - continue; // eslint-disable-line no-continue - } - if (!value) { - log.warn(`Unsetting binding "${name}" in render pipeline "${this.id}"`)(); - } - switch (binding.type) { - case 'uniform': - // @ts-expect-error - if (!(value instanceof WEBGLBuffer) && !(value.buffer instanceof WEBGLBuffer)) { - throw new Error('buffer value'); - } - break; - case 'texture': - if ( - !( - value instanceof WEBGLTextureView || - value instanceof WEBGLTexture || - value instanceof WEBGLFramebuffer - ) - ) { - throw new Error(`${this} Bad texture binding for ${name}`); - } - break; - case 'sampler': - log.warn(`Ignoring sampler ${name}`)(); - break; - default: - throw new Error(binding.type); - } - - this.bindings[name] = value; - } - } - /** @todo needed for portable model * @note The WebGL API is offers many ways to draw things * This function unifies those ways into a single call using common parameters with sane defaults @@ -184,6 +118,8 @@ export class WEBGLRenderPipeline extends RenderPipeline { firstInstance?: number; baseVertex?: number; transformFeedback?: WEBGLTransformFeedback; + bindings?: Record; + uniforms?: Record; }): boolean { const { renderPass, @@ -198,7 +134,9 @@ export class WEBGLRenderPipeline extends RenderPipeline { // firstIndex, // firstInstance, // baseVertex, - transformFeedback + transformFeedback, + bindings = {}, + uniforms = {} } = options; const glDrawMode = getGLDrawMode(topology); @@ -216,7 +154,7 @@ export class WEBGLRenderPipeline extends RenderPipeline { // Note: async textures set as uniforms might still be loading. // Now that all uniforms have been updated, check if any texture // in the uniforms is not yet initialized, then we don't draw - if (!this._areTexturesRenderable()) { + if (!this._areTexturesRenderable(bindings)) { log.info(2, `RenderPipeline:${this.id}.draw() aborted - textures not yet loaded`)(); // Note: false means that the app needs to redraw the pipeline again. return false; @@ -239,8 +177,8 @@ export class WEBGLRenderPipeline extends RenderPipeline { } // We have to apply bindings before every draw call since other draw calls will overwrite - this._applyBindings(); - this._applyUniforms(); + this._applyBindings(bindings, {disableWarnings: this.props.disableWarnings}); + this._applyUniforms(uniforms); const webglRenderPass = renderPass as WEBGLRenderPass; @@ -402,14 +340,11 @@ export class WEBGLRenderPipeline extends RenderPipeline { * Update a texture if needed (e.g. from video) * Note: This is currently done before every draw call */ - _areTexturesRenderable() { + _areTexturesRenderable(bindings: Record) { let texturesRenderable = true; for (const bindingInfo of this.shaderLayout.bindings) { - if ( - !this.bindings[bindingInfo.name] && - !this.bindings[bindingInfo.name.replace(/Uniforms$/, '')] - ) { + if (!bindings[bindingInfo.name] && !bindings[bindingInfo.name.replace(/Uniforms$/, '')]) { log.warn(`Binding ${bindingInfo.name} not found in ${this.id}`)(); texturesRenderable = false; } @@ -426,7 +361,7 @@ export class WEBGLRenderPipeline extends RenderPipeline { } /** Apply any bindings (before each draw call) */ - _applyBindings() { + _applyBindings(bindings: Record, _options?: {disableWarnings?: boolean}) { // If we are using async linking, we need to wait until linking completes if (this.linkStatus !== 'success') { return; @@ -439,8 +374,7 @@ export class WEBGLRenderPipeline extends RenderPipeline { let uniformBufferIndex = 0; for (const binding of this.shaderLayout.bindings) { // Accept both `xyz` and `xyzUniforms` as valid names for `xyzUniforms` uniform block - const value = - this.bindings[binding.name] || this.bindings[binding.name.replace(/Uniforms$/, '')]; + const value = bindings[binding.name] || bindings[binding.name.replace(/Uniforms$/, '')]; if (!value) { throw new Error(`No value for binding ${binding.name} in ${this.id}`); } @@ -519,10 +453,10 @@ export class WEBGLRenderPipeline extends RenderPipeline { * Due to program sharing, uniforms need to be reset before every draw call * (though caching will avoid redundant WebGL calls) */ - _applyUniforms() { + _applyUniforms(uniforms: Record) { for (const uniformLayout of this.shaderLayout.uniforms || []) { const {name, location, type, textureUnit} = uniformLayout; - const value = this.uniforms[name] ?? textureUnit; + const value = uniforms[name] ?? textureUnit; if (value !== undefined) { setUniform(this.device.gl, location, type, value); } diff --git a/modules/webgpu/src/adapter/resources/webgpu-render-pass.ts b/modules/webgpu/src/adapter/resources/webgpu-render-pass.ts index 8d4cd8094f..ea2dffb66d 100644 --- a/modules/webgpu/src/adapter/resources/webgpu-render-pass.ts +++ b/modules/webgpu/src/adapter/resources/webgpu-render-pass.ts @@ -19,6 +19,9 @@ export class WebGPURenderPass extends RenderPass { /** Active pipeline */ pipeline: WebGPURenderPipeline | null = null; + /** Latest bindings applied to this pass */ + bindings: Record = {}; + constructor(device: WebGPUDevice, props: RenderPassProps = {}) { super(device, props); this.device = device; @@ -78,8 +81,8 @@ export class WebGPURenderPass extends RenderPass { /** Sets an array of bindings (uniform buffers, samplers, textures, ...) */ setBindings(bindings: Record): void { - this.pipeline?.setBindings(bindings); - const bindGroup = this.pipeline?._getBindGroup(); + this.bindings = bindings; + const bindGroup = this.pipeline?._getBindGroup(bindings); if (bindGroup) { this.handle.setBindGroup(0, bindGroup); } diff --git a/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts b/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts index 9349c718ba..c09c375392 100644 --- a/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts +++ b/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts @@ -26,9 +26,7 @@ export class WebGPURenderPipeline extends RenderPipeline { readonly fs: WebGPUShader | null = null; /** For internal use to create BindGroups */ - private _bindings: Record; private _bindGroupLayout: GPUBindGroupLayout | null = null; - private _bindGroup: GPUBindGroup | null = null; override get [Symbol.toStringTag]() { return 'WebGPURenderPipeline'; @@ -56,8 +54,6 @@ export class WebGPURenderPipeline extends RenderPipeline { // Note: Often the same shader in WebGPU this.vs = props.vs as WebGPUShader; this.fs = props.fs as WebGPUShader; - - this._bindings = {...this.props.bindings}; } override destroy(): void { @@ -66,20 +62,6 @@ export class WebGPURenderPipeline extends RenderPipeline { this.handle = null; } - /** - * @todo Use renderpass.setBindings() ? - * @todo Do we want to expose BindGroups in the API and remove this? - */ - setBindings(bindings: Record): void { - // Invalidate the cached bind group if any value has changed - for (const [name, binding] of Object.entries(bindings)) { - if (this._bindings[name] !== binding) { - this._bindGroup = null; - } - } - Object.assign(this._bindings, bindings); - } - /** @todo - should this be moved to renderpass? */ draw(options: { renderPass: RenderPass; @@ -91,6 +73,8 @@ export class WebGPURenderPipeline extends RenderPipeline { firstIndex?: number; firstInstance?: number; baseVertex?: number; + bindings?: Record; + uniforms?: Record; }): boolean { const webgpuRenderPass = options.renderPass as WebGPURenderPass; @@ -103,7 +87,7 @@ export class WebGPURenderPipeline extends RenderPipeline { }); // Set bindings (uniform buffers, textures etc) - const bindGroup = this._getBindGroup(); + const bindGroup = this._getBindGroup(options.bindings || {}); if (bindGroup) { webgpuRenderPass.handle.setBindGroup(0, bindGroup); } @@ -136,7 +120,7 @@ export class WebGPURenderPipeline extends RenderPipeline { } /** Return a bind group created by setBindings */ - _getBindGroup() { + _getBindGroup(bindings: Record) { if (this.shaderLayout.bindings.length === 0) { return null; } @@ -145,12 +129,7 @@ export class WebGPURenderPipeline extends RenderPipeline { this._bindGroupLayout = this._bindGroupLayout || this.handle.getBindGroupLayout(0); // Set up the bindings - // TODO what if bindings change? We need to rebuild the bind group! - this._bindGroup = - this._bindGroup || - getBindGroup(this.device.handle, this._bindGroupLayout, this.shaderLayout, this._bindings); - - return this._bindGroup; + return getBindGroup(this.device.handle, this._bindGroupLayout, this.shaderLayout, bindings); } /** From 43b99524892f083f4d0a31cb32ac213dd1ceec14 Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:16:21 -0500 Subject: [PATCH 05/12] Add bindings default to render pipeline --- modules/core/src/adapter/resources/render-pipeline.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/core/src/adapter/resources/render-pipeline.ts b/modules/core/src/adapter/resources/render-pipeline.ts index ac6e069678..735585a0b5 100644 --- a/modules/core/src/adapter/resources/render-pipeline.ts +++ b/modules/core/src/adapter/resources/render-pipeline.ts @@ -132,6 +132,7 @@ export abstract class RenderPipeline extends Resource { colorAttachmentFormats: undefined!, depthStencilAttachmentFormat: undefined!, - parameters: {} + parameters: {}, + bindings: undefined! }; } From d2c527e2f8afba6e333167c534b7d1d4df30535b Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:46:53 -0500 Subject: [PATCH 06/12] Add disableWarnings support to render pipeline props --- modules/core/src/adapter/resources/render-pipeline.ts | 4 ++++ modules/engine/src/model/model.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/modules/core/src/adapter/resources/render-pipeline.ts b/modules/core/src/adapter/resources/render-pipeline.ts index 735585a0b5..718f6d826a 100644 --- a/modules/core/src/adapter/resources/render-pipeline.ts +++ b/modules/core/src/adapter/resources/render-pipeline.ts @@ -50,6 +50,9 @@ export type RenderPipelineProps = ResourceProps & { /** Parameters that are controlled by pipeline */ parameters?: RenderPipelineParameters; + /** Some applications intentionally supply unused attributes and bindings, and want to disable warnings */ + disableWarnings?: boolean; + // Dynamic bindings (TODO - pipelines should be immutable, move to RenderPass) /** Buffers, Textures, Samplers for the shader bindings */ bindings?: Record; @@ -133,6 +136,7 @@ export abstract class RenderPipeline extends Resource { depthStencilAttachmentFormat: undefined!, parameters: {}, + disableWarnings: false, bindings: undefined! }; } diff --git a/modules/engine/src/model/model.ts b/modules/engine/src/model/model.ts index d2fce1c11f..e212d610a7 100644 --- a/modules/engine/src/model/model.ts +++ b/modules/engine/src/model/model.ts @@ -64,6 +64,8 @@ export type ModelProps = Omit & { shaderInputs?: ShaderInputs; /** Bindings */ bindings?: Record; + /** WebGL-only uniforms */ + uniforms?: Record; /** Parameters that are built into the pipeline */ parameters?: RenderPipelineParameters; From f1343bd0ac3e774373f87e0c7a5559f75733e652 Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:59:51 -0500 Subject: [PATCH 07/12] Include parameters in WebGL pipeline hash --- modules/engine/src/factories/pipeline-factory.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/engine/src/factories/pipeline-factory.ts b/modules/engine/src/factories/pipeline-factory.ts index 8422d02238..5973226ba4 100644 --- a/modules/engine/src/factories/pipeline-factory.ts +++ b/modules/engine/src/factories/pipeline-factory.ts @@ -200,8 +200,9 @@ export class PipelineFactory { const {type} = this.device; switch (type) { case 'webgl': - // WebGL is more dynamic - return `${type}/R/${vsHash}/${fsHash}V${varyingHash}BL${bufferLayoutHash}`; + // WebGL is more dynamic but still needs to avoid sharing pipelines when render parameters differ + const webglParameterHash = this._getHash(JSON.stringify(props.parameters)); + return `${type}/R/${vsHash}/${fsHash}V${varyingHash}P${webglParameterHash}BL${bufferLayoutHash}`; case 'webgpu': default: From feaa8917966643b86e2418c6589b77de98af5932 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 17 Mar 2026 08:03:06 -0400 Subject: [PATCH 08/12] fixes --- docs/api-reference/core/device.md | 54 ++-- .../core/resources/compute-pipeline.md | 23 +- .../core/resources/render-pipeline.md | 100 +++--- docs/api-reference/engine/pipeline-factory.md | 32 +- docs/whats-new.md | 12 +- modules/core/src/adapter/device.ts | 18 +- .../src/adapter/resources/render-pipeline.ts | 10 + .../core/src/adapter/resources/resource.ts | 48 ++- .../resources/shared-render-pipeline.ts | 24 ++ modules/core/src/index.ts | 1 + .../engine/src/factories/pipeline-factory.ts | 75 ++++- .../engine/src/factories/shader-factory.ts | 2 +- modules/engine/src/model/model.ts | 3 + .../engine/test/lib/pipeline-factory.spec.ts | 301 +++++++++++++++++- .../resources/webgl-render-pipeline.ts | 278 +++++----------- .../resources/webgl-shared-render-pipeline.ts | 206 ++++++++++++ modules/webgl/src/adapter/webgl-device.ts | 9 + .../helpers/generate-mipmaps-webgpu.ts | 5 - .../resources/webgpu-render-pipeline.ts | 43 ++- 19 files changed, 931 insertions(+), 313 deletions(-) create mode 100644 modules/core/src/adapter/resources/shared-render-pipeline.ts create mode 100644 modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts diff --git a/docs/api-reference/core/device.md b/docs/api-reference/core/device.md index a0d37a2b2b..00ee02898d 100644 --- a/docs/api-reference/core/device.md +++ b/docs/api-reference/core/device.md @@ -12,7 +12,7 @@ Note that the actual `Device` returned by `luma.createDevice()` will be either a `WebGLDevice` wrapping a WebGL context or a `WebGPUDevice` wrapping a WebGPU device based on what the run-time environment supports. -The `Device` API is intentionally designed to be similar to the +The `Device` API is intentionally designed to be similar to the WebGPU [`GPUDevice`](https://www.w3.org/TR/webgpu/#gpu-device) class API with changes to enable a WebGL2 implementation. @@ -22,7 +22,7 @@ Create a new `Device`, auto creating a canvas and a new WebGL 2 context. See [`l ```typescript import {Device} from '@luma.gl/core'; -const device = new luma.createDevice({type: 'webgl2', ...}); +const device = new luma.createDevice({type: 'webgl2', ...}); ``` Attaching a `Device` to an externally created `WebGL2RenderingContext`. @@ -74,6 +74,7 @@ Specifies props to use when luma creates the device. | `debug?`: `boolean` | `false` | Extra checks (wait for shader compilation, framebuffer completion, WebGL API errors will throw exceptions). | | `debugShaders?`: `'errors' 'warnings' 'always' 'never'` | `'error'` | Display shader source code with inline errors in the canvas. | | `debugFramebuffers?: boolean` | `false` | Show small copy of the contents of updated Framebuffers in the canvas. | +| `debugFactories?: boolean` | `false` | Log pipeline-factory cache create/reuse/release activity. | | `debugWebGL?: boolean` | `false` | traces WebGL API calls to the console (via Khronos WebGLDeveloperTools). | | `debugSpectorJS?: boolean` | `false` | Initialize the SpectorJS WebGL debugger. | | `debugSpectorJSUrl?: string` | CDN url | SpectorJS URL. Override if different SpectorJS version is desired (or if CDN is down). | @@ -82,9 +83,21 @@ Specifies props to use when luma creates the device. Learn more GPU debugging in our [Debugging](../../developer-guide/debugging.md) guide. ::: +#### Internal caching props + +These props are primarily intended for internal tuning and testing of factory-managed resource reuse. + +| Property | Default | Description | +| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------- | +| `_cacheShaders?: boolean` | `true` | Enable shader caching through `ShaderFactory`. | +| `_destroyShaders?: boolean` | `true` | Destroy cached shaders when their factory reference count reaches zero. | +| `_cachePipelines?: boolean` | `true` | Enable `PipelineFactory` wrapper caching. | +| `_sharePipelines?: boolean` | `true` | When pipeline caching is enabled, allow compatible WebGL render-pipeline wrappers to share a linked `WebGLProgram`. | +| `_destroyPipelines?: boolean` | `true` | Destroy cached pipelines when their factory reference count reaches zero. | + #### WebGLContextAttributes -For detailed control over WebGL context can specify what [`WebGLContextAttributes`][webgl-attributes] to use if luma creates the WebGL context. +For detailed control over WebGL context can specify what [`WebGLContextAttributes`][webgl-attributes] to use if luma creates the WebGL context. | `WebGLContextAttributes` | Default | Description | | ---------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | @@ -112,7 +125,7 @@ A string identifier, for debug purposes. ### statsManager ```typescript -statsManager: StatsManager +statsManager: StatsManager; ``` Provides access to bags of stats containing information about resource usage and performance of the device. @@ -120,7 +133,7 @@ Provides access to bags of stats containing information about resource usage and ### props ```typescript -props: Required +props: Required; ``` A readonly copy of the props that were used to create this device. @@ -128,7 +141,7 @@ A readonly copy of the props that were used to create this device. ### userData ```typescript -userData: Record +userData: Record; ``` Reserved for the application. @@ -136,7 +149,7 @@ Reserved for the application. ### info ```typescript -info: DeviceInfo +info: DeviceInfo; ``` Information about the device (vendor, versions etc). @@ -154,10 +167,11 @@ Get debug information about the device: | `shadingLanguageVersion` | `number` | shading language version | Remarks: + - Shading language version is the highest supported version of the device's shading language. -- Version numbers are calculated as: ` * 100 + * 10 + `. -- The WGSL version is always `100` -- The GLSL version is always `300` (WebGL2). +- Version numbers are calculated as: ` * 100 + * 10 + `. +- The WGSL version is always `100` +- The GLSL version is always `300` (WebGL2). - Sometimes a vendor provides multiple backends (e.g. Apple ANGLE vs Apple Metal) - WebGPU Devices currently do not provide much information due to limitations in the WebGPU API. - WebGL Devices can usually provide rich information (through the `WEBGL_debug_renderer_info` extension). @@ -165,7 +179,7 @@ Remarks: ### features ```typescript -features: Set +features: Set; ``` Applications can determine whether the device implements an optional features by checking `device.features.has(...)`. @@ -173,7 +187,7 @@ Applications can determine whether the device implements an optional features by ### limits ```typescript -limits: DeviceLimits +limits: DeviceLimits; ``` An object with various device limits. WebGPU style. @@ -205,7 +219,7 @@ Check if device supports rendering to a specific texture format. ### isLost ```typescript -isLost: boolean +isLost: boolean; ``` True if the device is already lost (GPU is disconnected). @@ -213,10 +227,9 @@ True if the device is already lost (GPU is disconnected). ### lost ```typescript -lost: Promise<{reason: 'destroyed', message: string}> +lost: Promise<{reason: 'destroyed'; message: string}>; ``` - Promise that resolves with an error message if the device is lost (GPU is disconnected). :::info @@ -254,14 +267,14 @@ Use the static `Device.create()` method to create classes. Releases resources associated with this `Device`. :::info -WebGPU only. Calling `device.destroy()` on a WebGL `Device` will not immediately release GPU resources. +WebGPU only. Calling `device.destroy()` on a WebGL `Device` will not immediately release GPU resources. The WebGL API does not provide a context destroy function, instead relying on garbage collection to eventually release the resources. ::: :::caution Interaction between `Device.destroy()`, `Device.lost` and `Device.isLost` is implementation-dependent. -The application should not assume that destroying a device triggers a device loss, +The application should not assume that destroying a device triggers a device loss, or that the `lost` promise is resolved before any API errors are triggered by access to the destroyed device. ::: @@ -319,7 +332,7 @@ In TypeScript applications this helps applications avoid having to repeatedly ch submit(): void ``` -The application should call `device.submit()` after rendering of a frame is complete +The application should call `device.submit()` after rendering of a frame is complete to ensure that the generated command queue is submitted to the GPU. ### createBuffer @@ -418,8 +431,7 @@ Triggers device loss (see below). After this call, the `Device.lost` promise wil - Returns `true` if an actual or emulated device loss was triggered, `false` otherwise. Note that even if device loss emulation is not supported by the platform this function will still update the `Device` instance to indicate that the device was lost, however the device can still be used. - :::note -The `loseDevice()` method is primarily intended for debugging of device loss handling and should not be relied upon for production code. -`loseDevice()` can currently only emulate context loss on WebGL devices on platform's where WebGL API provides the required `WEBGL_lose_context` WebGL debug extension. +The `loseDevice()` method is primarily intended for debugging of device loss handling and should not be relied upon for production code. +`loseDevice()` can currently only emulate context loss on WebGL devices on platform's where WebGL API provides the required `WEBGL_lose_context` WebGL debug extension. ::: diff --git a/docs/api-reference/core/resources/compute-pipeline.md b/docs/api-reference/core/resources/compute-pipeline.md index 905841507e..c90ad824f3 100644 --- a/docs/api-reference/core/resources/compute-pipeline.md +++ b/docs/api-reference/core/resources/compute-pipeline.md @@ -12,7 +12,7 @@ A `ComputePipeline` holds a compiled and linked compute shader. Create and run a compute shader that multiplies an array of numbers by 2. ```ts -const source = /*WGSL*/`\ +const source = /*WGSL*/ `\ @group(0) @binding(0) var data: array; @compute @workgroup_size(1) fn main(@builtin(global_invocation_id) id: vec3) { let i = id.x; @@ -29,7 +29,7 @@ const computePipeline = webgpuDevice.createComputePipeline({ const workBuffer = webgpuDevice.createBuffer({ byteLength: 4, - usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST, + usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST }); workBuffer.write(new Int32Array([2])); @@ -63,7 +63,7 @@ const computedData = new Int32Array(await workBuffer.readAsync()); ### `constructor()` -`ComputePipeline` is an abstract class and cannot be instantiated directly. Create with +`ComputePipeline` is an abstract class and cannot be instantiated directly. Create with ```typescript const computePipeline = device.createComputePipeline({...}) @@ -74,4 +74,21 @@ const computePipeline = device.createComputePipeline({...}) ```typescript destroy(): void ``` + Free up any GPU resources associated with this compute pipeline immediately (instead of waiting for garbage collection). + +## Performance + +Creating compute pipelines can be expensive because it may trigger shader compilation and backend pipeline creation work. Applications that may request the same compute pipeline more than once should prefer `PipelineFactory` over calling `device.createComputePipeline()` directly. + +```ts +import {PipelineFactory} from '@luma.gl/engine'; + +const pipelineFactory = PipelineFactory.getDefaultPipelineFactory(device); +const computePipeline = pipelineFactory.createComputePipeline({ + shader, + shaderLayout +}); +``` + +Using `PipelineFactory` allows compatible compute pipelines to be cached and reused instead of recreated. diff --git a/docs/api-reference/core/resources/render-pipeline.md b/docs/api-reference/core/resources/render-pipeline.md index b416a3d3e1..d38dee2b04 100644 --- a/docs/api-reference/core/resources/render-pipeline.md +++ b/docs/api-reference/core/resources/render-pipeline.md @@ -5,6 +5,7 @@ A `RenderPipeline` contains a matched pair of vertex and fragment [shaders](/doc A RenderPipeline controls the vertex and fragment shader stages, and can be used in GPURenderPassEncoder as well as GPURenderBundleEncoder. Render pipeline inputs are: + - bindings, according to the given bindingLayout - vertex and index buffers - the color attachments, Framebuffer @@ -12,12 +13,14 @@ Render pipeline inputs are: - parameters Render pipeline outputs are: + - buffer bindings with a type of "storage" - storageTexture bindings with a access of "write-only" - the color attachments, described by Framebuffer - the depth-stencil optional attachment, described by Framebuffer A render pipeline is comprised of the following render stages: + - Vertex fetch, from the buffers buffers - Vertex shader, props.vs - Primitive assembly, controlled by @@ -76,45 +79,44 @@ const pipeline = device.createRenderPipeline({vs, fs, varyings: ['gl_Position']} ### RenderPipelineProps -| Property | Type | Default | Mutable? | Description | -| ---------------- | -------------------------- | ------- | -------- | ------------------------------------------------------------------------ | -| Shader | -| `vs?` | `Shader` | `null` | No | Compiled vertex shader | -| `vertexEntryPoint?` | `string` | - | No | Vertex shader entry point (defaults to 'main'). WGSL only | -| `vsConstants?` | `Record` | | No | Constants to apply to compiled vertex shader (WGSL only) | -| `fs?` | `Shader` | `null` | No | Compiled fragment shader | -| `fragmentEntryPoint?` | `stringy` | | No | Fragment shader entry point (defaults to 'main'). WGSL only | -| `fsConstants?` | ` Record` | | No | Constants to apply to compiled fragment shader (WGSL only) | -| ShaderLayout | -| `topology?` | `PrimitiveTopology;` | | | Determines how vertices are read from the 'vertex' attributes | -| `shaderLayout?` | `ShaderLayout` | `null` | | Describes the attributes and bindings exposed by the pipeline shader(s). | -| `bufferLayout?` | `BufferLayout` | | | | -| GPU Parameters | -| `parameters?` | `RenderPipelineParameters` | | | Parameters that are controlled by pipeline | -| Dynamic settings | -| `vertexCount?` | `number` | | | Number of "rows" in 'vertex' buffers | -| `instanceCount?` | `number` | | | Number of "rows" in 'instance' buffers | -| `indices?` | `Buffer` | `null` | | Optional index buffer | -| `attributes?` | `Record` | | | Buffers for attributes | -| `bindings?` | `Record` | | | Buffers, Textures, Samplers for the shader bindings | -| `uniforms?` | `Record` | | | uniforms (WebGL only) | - - * A default mapping of one buffer per attribute is always created. - * @note interleaving attributes into the same buffer does not increase the number of attributes - * that can be used in a shader (16 on many systems). +| Property | Type | Default | Mutable? | Description | +| --------------------- | -------------------------- | ------- | -------- | ------------------------------------------------------------------------ | +| Shader | +| `vs?` | `Shader` | `null` | No | Compiled vertex shader | +| `vertexEntryPoint?` | `string` | - | No | Vertex shader entry point (defaults to 'main'). WGSL only | +| `vsConstants?` | `Record` | | No | Constants to apply to compiled vertex shader (WGSL only) | +| `fs?` | `Shader` | `null` | No | Compiled fragment shader | +| `fragmentEntryPoint?` | `stringy` | | No | Fragment shader entry point (defaults to 'main'). WGSL only | +| `fsConstants?` | ` Record` | | No | Constants to apply to compiled fragment shader (WGSL only) | +| ShaderLayout | +| `topology?` | `PrimitiveTopology;` | | | Determines how vertices are read from the 'vertex' attributes | +| `shaderLayout?` | `ShaderLayout` | `null` | | Describes the attributes and bindings exposed by the pipeline shader(s). | +| `bufferLayout?` | `BufferLayout` | | | | +| GPU Parameters | +| `parameters?` | `RenderPipelineParameters` | | | Parameters that are controlled by pipeline | +| Dynamic settings | +| `vertexCount?` | `number` | | | Number of "rows" in 'vertex' buffers | +| `instanceCount?` | `number` | | | Number of "rows" in 'instance' buffers | +| `indices?` | `Buffer` | `null` | | Optional index buffer | +| `attributes?` | `Record` | | | Buffers for attributes | +| `bindings?` | `Record` | | | Buffers, Textures, Samplers for the shader bindings | +| `uniforms?` | `Record` | | | uniforms (WebGL only) | + +- A default mapping of one buffer per attribute is always created. +- @note interleaving attributes into the same buffer does not increase the number of attributes +- that can be used in a shader (16 on many systems). ### PrimitiveTopology Describes how primitives (points, lines or triangles) are formed from vertexes. -| Value | WebGL | WebGPU | Description | -| ---------------------- | ----- | ------ | ------------------------------------------------------------------------------------------------------ | -| `'point-list'` | ✅ | ✅ | Each vertex defines a point primitive. | -| `'line-list'` | ✅ | ✅ | Each consecutive pair of two vertices defines a line primitive. | -| `'line-strip'` | ✅ | ✅ | Each vertex after the first defines a line primitive between it and the previous vertex. | -| `'triangle-list'` | ✅ | ✅ | Each consecutive triplet of three vertices defines a triangle primitive. | -| `'triangle-strip'` | ✅ | ✅ | Each vertex after the first two defines a triangle primitive between it and the previous two vertices. | - +| Value | WebGL | WebGPU | Description | +| ------------------ | ----- | ------ | ------------------------------------------------------------------------------------------------------ | +| `'point-list'` | ✅ | ✅ | Each vertex defines a point primitive. | +| `'line-list'` | ✅ | ✅ | Each consecutive pair of two vertices defines a line primitive. | +| `'line-strip'` | ✅ | ✅ | Each vertex after the first defines a line primitive between it and the previous vertex. | +| `'triangle-list'` | ✅ | ✅ | Each consecutive triplet of three vertices defines a triangle primitive. | +| `'triangle-strip'` | ✅ | ✅ | Each vertex after the first two defines a triangle primitive between it and the previous two vertices. | ## Members @@ -123,7 +125,6 @@ Describes how primitives (points, lines or triangles) are formed from vertexes. - `handle`: `unknown` - holds the underlying WebGL or WebGPU shader object - `props`: `BufferProps` - holds a copy of the `BufferProps` used to create this `Buffer`. - ## Methods ## constructor @@ -140,7 +141,6 @@ Creates a new pipeline using the supplied vertex and fragment shaders. The shade const pipeline = device.createRenderPipeline(props: RenderProps); ``` - ```ts const pipeline = device.createRenderPipeline({ id: 'my-identifier', @@ -155,14 +155,12 @@ const pipeline = device.createRenderPipeline({ - `fs` (`FragmentShader`|`String`) - A fragment shader object, or source as a string. - `varyings` WebGL (`String[]`) - a list of names of varyings. - WebGL References [WebGLProgram](https://developer.mozilla.org/en-US/docs/Web/API/WebGLProgram), [gl.createProgram](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/createProgram) ### destroy() Deletes resources held by pipeline. Note: Does not currently delete shaders (to enable shader sharing and caching). - ### draw(opts) : RenderPipeline `RenderPipeline.draw()` is the entry point for running shaders, rendering and (optionally calculating data using transform feedback techniques). @@ -208,6 +206,7 @@ Parameters for drawing a limited range - `end` - hint to GPU, activates `gl.drawElementsRange` Returns: + - `true` if successful, `false` if draw call is blocked due to resources (the pipeline itself or textures) not yet being initialized. Notes: @@ -218,6 +217,21 @@ Notes: - A `Sampler` will only be bound if there is a matching Texture with the same key in the supplied `uniforms` object. - Once a uniform is set, it's size should not be changed. This is only a concern for array uniforms. +## Performance + +Creating render pipelines is relatively expensive because it can trigger shader compilation, linking, and backend-specific pipeline setup. Reusing compatible pipelines is therefore important for both startup time and dynamic workloads that build models or materials on demand. + +For application code, prefer using `PipelineFactory` instead of calling `device.createRenderPipeline()` directly: + +```ts +import {PipelineFactory} from '@luma.gl/engine'; + +const pipelineFactory = PipelineFactory.getDefaultPipelineFactory(device); +const pipeline = pipelineFactory.createRenderPipeline({vs, fs, ...}); +``` + +This lets luma.gl cache compatible wrapper pipelines, and on WebGL it can also share the linked `WebGLProgram` across compatible `RenderPipeline` instances. + The following WebGL APIs are called in this function: ### setBindings() @@ -238,5 +252,11 @@ The following WebGL APIs are called by the WEBGLRenderPipeline: [gl.drawElementsInstanced](https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext/drawElementsInstanced), [gl.drawArraysInstanced](https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext/drawArraysInstanced), [gl.getExtension](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/getExtension), [ANGLE_instanced_arrays](https://developer.mozilla.org/en-US/docs/Web/API/ANGLE_instanced_arrays), -[gl.drawElementsInstancedANGLE](https://developer.mozilla.org/en-US/docs/Web/API/ANGLE_instanced_arrays/drawElementsInstancedANGLE), -[gl.drawArraysInstancedANGLE](https://developer.mozilla.org/en-US/docs/Web/API/ANGLE_instanced_arrays/drawArraysInstancedANGLE) + +Additional WebGL behavior: + +- Compatible WebGL `RenderPipeline` instances may share the same linked `WebGLProgram` internally. +- Shared program reuse is an optimization only. Each `RenderPipeline` still keeps its own default `topology` and `parameters`, which are used when `draw()` does not receive explicit overrides. +- In practice this means pipeline wrapper identity and underlying `WebGLProgram` identity are not always the same on WebGL. + [gl.drawElementsInstancedANGLE](https://developer.mozilla.org/en-US/docs/Web/API/ANGLE_instanced_arrays/drawElementsInstancedANGLE), + [gl.drawArraysInstancedANGLE](https://developer.mozilla.org/en-US/docs/Web/API/ANGLE_instanced_arrays/drawArraysInstancedANGLE) diff --git a/docs/api-reference/engine/pipeline-factory.md b/docs/api-reference/engine/pipeline-factory.md index 8330236a4c..688b9f2fbe 100644 --- a/docs/api-reference/engine/pipeline-factory.md +++ b/docs/api-reference/engine/pipeline-factory.md @@ -1,6 +1,6 @@ # PipelineFactory -The `PipelineFactory` class provides a `createRenderPipeline()` method that caches and reuses render pipelines. +The `PipelineFactory` class provides `createRenderPipeline()` and `createComputePipeline()` methods that cache and reuse pipelines. The purpose of the pipeline factory is to speed up applications that tend to create multiple render pipelines with the same shaders and other properties. By returning the same cached pipeline, and when used alongside a `ShaderFactory`, the pipeline factory minimizes the amount of time spent in shader compilation and linking. @@ -13,9 +13,6 @@ should consider replacing normal pipeline creation. It is possible to create multiple pipeline factories, but normally applications rely on the default pipeline factory that is created for each device. -Limitations: -- `ComputePipeline` caching is not currently supported. - ## Usage An application that tends to create multiple identical `RenderPipeline` instances @@ -133,7 +130,7 @@ While it is possible to create multiple factories, most applications will use th ### createRenderPipeline() -Get a program that fits the parameters provided. +Get a program that fits the parameters provided. ```typescript createRenderPipeline(props: RenderPipelineProps): RenderPipeline @@ -148,15 +145,34 @@ If one is already cached, return it, otherwise create and cache a new one. - `modules`: Array of module objects to include in the shaders. - `inject`: Object of hook injections to include in the shaders. -### release() +### createComputePipeline() + +Get a compute pipeline that fits the parameters provided. ```typescript -release(pipeline: RenderPipeline): void +createComputePipeline(props: ComputePipelineProps): ComputePipeline ``` -Indicates that a pipeline is no longer in use. Each call to `createRenderPipeline()` increments a reference count, and only when all references to a pipeline are released, the pipeline is destroyed and deleted from the cache. +If one is already cached, return it, otherwise create and cache a new one. +### release() + +```typescript +release(pipeline: RenderPipeline | ComputePipeline): void +``` + +Indicates that a pipeline is no longer in use. Each call to `createRenderPipeline()` or `createComputePipeline()` increments a reference count, and only when all references to a pipeline are released, the pipeline is destroyed and deleted from the cache. ### getUniforms(program: Program): Object Returns an object containing all the uniforms defined for the program. Returns `null` if `program` isn't managed by the `PipelineFactory`. + +## WebGL notes + +- On WebGL, `PipelineFactory` may return different cached `RenderPipeline` wrappers that share one linked `WebGLProgram`. +- Wrapper caching still respects pipeline-level defaults such as `topology`, `parameters`, and layout-related props. +- This lets WebGL reduce shader-link overhead without changing the per-pipeline behavior seen by direct `RenderPipeline.draw()` callers. +- Device props can tune this behavior: + - `_cachePipelines` enables wrapper caching. + - `_sharePipelines` enables shared WebGL program reuse across compatible wrappers. + - `_destroyPipelines` controls whether unused cached pipelines are destroyed when their reference count reaches zero. diff --git a/docs/whats-new.md b/docs/whats-new.md index e50e6a0616..51a576965f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -33,11 +33,15 @@ Target Date: April 2026 - **compressed texture** support (but note that WebGPU is stricter than WebGL and requires block-aligned textures). - **texture readback** improvements +**@luma.gl/webgl** + +- **RenderPipeline optimization** - Compatible WebGL render pipelines now share linked `WebGLProgram`s, reducing pipeline creation overhead while preserving per-pipeline defaults. + **@luma.gl/gltf** - **WebGPU support** - glTF models can now be rendered in WebGPU. - **Joint/Skin Animations** - Support for glTF animations now include joint and skin animations. -- **Lighting** - luma.gl Light definitions are now extracted if the `KHR_lights_punctual` glTF extension is present in the glTF file. +- **Lighting** - luma.gl Light definitions are now extracted if the `KHR_lights_punctual` glTF extension is present in the glTF file. - **`linear` texture filtering** - default texture filtering is now `linear` instead of `nearest` for improved texture rendering. **@luma.gl/shadertools** @@ -67,19 +71,20 @@ Production quality WebGPU backend - [`Buffer.mapAndReadAsync()`] New method that reads directly from buffer memory without performing a copy. - [`Buffer.mapAndWriteAsync()`] New method that writes directly to buffer memory. - [`Texture`] - - `Texture` class refactors complete, see upgrade guide. + - `Texture` class refactors complete, see upgrade guide. - Shader type APIs have been improved. - `CommandEncoder`/`CommandBuffer` API improvements - `Fence` - New synchronization primitive created with `device.createFence()` - `CanvasContext` API simplifications (see upgrade guide). - [Texture Formats](/docs/api-reference/core/texture-formats). Adds support for the new texture formats added in Chrome 132 (currently require setting chrome://flags/#enable-unsafe-webgpu) + - `'r16unorm'`, `'rg16unorm'`, `'rgba16unorm'` (feature `'chromium-experimental-unorm16-texture-formats'`) - `'r16snorm'`, `'rg16snorm'`, `'rgba16snorm'` (feature `'chromium-experimental-snorm16-texture-formats'`) - [Vertex Formats](/docs/api-reference/core/vertex-formats) (added in Chrome v133 and v119) - Single component 8 and 16 bit formats are now supported by WebGPU: `'uint8'`, `'sint8'`, `'unorm8'`, `'snorm8'`, `'uint16'`, `'sint16'`, `'unorm16'`, `'snorm16'`, and `'float16'`. - - Note: 3 component formats are still missing in WebGPU. + - Note: 3 component formats are still missing in WebGPU. - `'unorm8x4-bgra'` - WebGPU only. Simplifies working with BGRA data. - `'unorm10-10-10-2` - Exposed since available in all WebGPU backends. Also supported by WebGL2. @@ -98,7 +103,6 @@ Production quality WebGPU backend - More shader modules ported to WGSL - ## Version 9.1 Target Date: Dec, 2024 diff --git a/modules/core/src/adapter/device.ts b/modules/core/src/adapter/device.ts index b736d127aa..f4c7adc5ac 100644 --- a/modules/core/src/adapter/device.ts +++ b/modules/core/src/adapter/device.ts @@ -16,6 +16,7 @@ import type {PresentationContext, PresentationContextProps} from './presentation import type {BufferProps} from './resources/buffer'; import {Buffer} from './resources/buffer'; import type {RenderPipeline, RenderPipelineProps} from './resources/render-pipeline'; +import type {SharedRenderPipeline} from './resources/shared-render-pipeline'; import type {ComputePipeline, ComputePipelineProps} from './resources/compute-pipeline'; import type {Sampler, SamplerProps} from './resources/sampler'; import type {Shader, ShaderProps} from './resources/shader'; @@ -296,10 +297,14 @@ export type DeviceProps = { _initializeFeatures?: boolean; /** Enable shader caching (via ShaderFactory) */ _cacheShaders?: boolean; + /** Destroy cached shaders when they become unused. */ + _destroyShaders?: boolean; /** Enable shader caching (via PipelineFactory) */ _cachePipelines?: boolean; - /** Never destroy cached shaders and pipelines */ - _cacheDestroyPolicy?: 'unused' | 'never'; + /** Enable sharing of backend render-pipeline implementations when caching is enabled. Currently used by WebGL. */ + _sharePipelines?: boolean; + /** Destroy cached pipelines when they become unused. */ + _destroyPipelines?: boolean; /** @deprecated Internal, Do not use directly! Use `luma.attachDevice()` to attach to pre-created contexts/devices. */ _handle?: unknown; // WebGL2RenderingContext | GPUDevice | null; @@ -380,8 +385,10 @@ export abstract class Device { _reuseDevices: false, _requestMaxLimits: true, _cacheShaders: true, + _destroyShaders: true, _cachePipelines: true, - _cacheDestroyPolicy: 'unused', + _sharePipelines: true, + _destroyPipelines: true, // TODO - Change these after confirming things work as expected _initializeFeatures: true, _disabledFeatures: { @@ -686,6 +693,11 @@ or create a device with the 'debug: true' prop.`; throw new Error('not implemented'); } + /** Internal helper for creating a shareable WebGL render-pipeline implementation. */ + _createSharedRenderPipelineWebGL(_props: RenderPipelineProps): SharedRenderPipeline { + throw new Error('_createSharedRenderPipelineWebGL() not implemented'); + } + /** * Internal helper that returns `true` when timestamp-query GPU timing should be * collected for this device. diff --git a/modules/core/src/adapter/resources/render-pipeline.ts b/modules/core/src/adapter/resources/render-pipeline.ts index 05c6a215af..7b854bb78c 100644 --- a/modules/core/src/adapter/resources/render-pipeline.ts +++ b/modules/core/src/adapter/resources/render-pipeline.ts @@ -11,6 +11,7 @@ import type { TextureFormatDepthStencil } from '@luma.gl/core/shadertypes/textures/texture-formats'; import type {Shader} from './shader'; +import type {SharedRenderPipeline} from './shared-render-pipeline'; import type {RenderPass} from './render-pass'; import {Resource, ResourceProps} from './resource'; import {VertexArray} from './vertex-array'; @@ -53,6 +54,9 @@ export type RenderPipelineProps = ResourceProps & { /** Some applications intentionally supply unused attributes and bindings, and want to disable warnings */ disableWarnings?: boolean; + /** Internal hook for backend-specific shared pipeline implementations. */ + _sharedRenderPipeline?: SharedRenderPipeline; + // Dynamic bindings (TODO - pipelines should be immutable, move to RenderPass) /** Buffers, Textures, Samplers for the shader bindings */ bindings?: Record; @@ -77,6 +81,10 @@ export abstract class RenderPipeline extends Resource { linkStatus: 'pending' | 'success' | 'error' = 'pending'; /** The hash of the pipeline */ hash: string = ''; + /** Optional lower-level implementation hash for shared backend pipeline state */ + implementationHash: string = ''; + /** Optional shared backend implementation */ + sharedRenderPipeline: SharedRenderPipeline | null = null; /** Whether shader or pipeline compilation/linking is still in progress */ get isPending(): boolean { @@ -100,6 +108,7 @@ export abstract class RenderPipeline extends Resource { super(device, props, RenderPipeline.defaultProps); this.shaderLayout = this.props.shaderLayout!; this.bufferLayout = this.props.bufferLayout || []; + this.sharedRenderPipeline = this.props._sharedRenderPipeline || null; } /** Draw call. Returns false if the draw call was aborted (due to resources still initializing) */ @@ -155,6 +164,7 @@ export abstract class RenderPipeline extends Resource { parameters: {}, disableWarnings: false, + _sharedRenderPipeline: undefined!, bindings: undefined! }; } diff --git a/modules/core/src/adapter/resources/resource.ts b/modules/core/src/adapter/resources/resource.ts index 2e5f2a04ec..e5456a4691 100644 --- a/modules/core/src/adapter/resources/resource.ts +++ b/modules/core/src/adapter/resources/resource.ts @@ -10,7 +10,7 @@ const CPU_HOTSPOT_PROFILER_MODULE = 'cpu-hotspot-profiler'; const RESOURCE_COUNTS_STATS = 'GPU Resource Counts'; const LEGACY_RESOURCE_COUNTS_STATS = 'Resource Counts'; const GPU_TIME_AND_MEMORY_STATS = 'GPU Time and Memory'; -const RESOURCE_COUNT_ORDER = [ +const BASE_RESOURCE_COUNT_ORDER = [ 'Resources', 'Buffers', 'Textures', @@ -28,7 +28,30 @@ const RESOURCE_COUNT_ORDER = [ 'CommandEncoders', 'CommandBuffers' ] as const; -const RESOURCE_COUNT_STAT_ORDER = RESOURCE_COUNT_ORDER.flatMap(resourceType => [ +const WEBGL_RESOURCE_COUNT_ORDER = [ + 'Resources', + 'Buffers', + 'Textures', + 'Samplers', + 'TextureViews', + 'Framebuffers', + 'QuerySets', + 'Shaders', + 'RenderPipelines', + 'SharedRenderPipelines', + 'ComputePipelines', + 'PipelineLayouts', + 'VertexArrays', + 'RenderPasss', + 'ComputePasss', + 'CommandEncoders', + 'CommandBuffers' +] as const; +const BASE_RESOURCE_COUNT_STAT_ORDER = BASE_RESOURCE_COUNT_ORDER.flatMap(resourceType => [ + `${resourceType} Created`, + `${resourceType} Active` +]); +const WEBGL_RESOURCE_COUNT_STAT_ORDER = WEBGL_RESOURCE_COUNT_ORDER.flatMap(resourceType => [ `${resourceType} Created`, `${resourceType} Active` ]); @@ -37,7 +60,6 @@ const ORDERED_STATS_CACHE = new WeakMap< {orderedStatNames: readonly string[]; statCount: number} >(); const ORDERED_STAT_NAME_SET_CACHE = new WeakMap>(); -const RESOURCE_COUNT_STATS_INITIALIZED = new WeakSet(); type CpuHotspotProfiler = { enabled?: boolean; @@ -197,8 +219,9 @@ export abstract class Resource { this._device.statsManager.getStats(RESOURCE_COUNTS_STATS), this._device.statsManager.getStats(LEGACY_RESOURCE_COUNTS_STATS) ]; + const orderedStatNames = getResourceCountStatOrder(this._device); for (const stats of statsObjects) { - initializeStats(stats, RESOURCE_COUNT_STAT_ORDER); + initializeStats(stats, orderedStatNames); } const name = this[Symbol.toStringTag]; for (const stats of statsObjects) { @@ -274,8 +297,9 @@ export abstract class Resource { this._device.statsManager.getStats(RESOURCE_COUNTS_STATS), this._device.statsManager.getStats(LEGACY_RESOURCE_COUNTS_STATS) ]; + const orderedStatNames = getResourceCountStatOrder(this._device); for (const stats of statsObjects) { - initializeStats(stats, RESOURCE_COUNT_STAT_ORDER); + initializeStats(stats, orderedStatNames); } for (const stats of statsObjects) { stats.get('Resources Created').incrementCount(); @@ -309,13 +333,6 @@ function selectivelyMerge(props: Props, defaultProps: Required): R } function initializeStats(stats: Stats, orderedStatNames: readonly string[]): void { - if ( - orderedStatNames === RESOURCE_COUNT_STAT_ORDER && - RESOURCE_COUNT_STATS_INITIALIZED.has(stats) - ) { - return; - } - const statsMap = stats.stats; let addedOrderedStat = false; for (const statName of orderedStatNames) { @@ -360,9 +377,10 @@ function initializeStats(stats: Stats, orderedStatNames: readonly string[]): voi Object.assign(statsMap, reorderedStats); ORDERED_STATS_CACHE.set(stats, {orderedStatNames, statCount}); - if (orderedStatNames === RESOURCE_COUNT_STAT_ORDER) { - RESOURCE_COUNT_STATS_INITIALIZED.add(stats); - } +} + +function getResourceCountStatOrder(device: Device): readonly string[] { + return device.type === 'webgl' ? WEBGL_RESOURCE_COUNT_STAT_ORDER : BASE_RESOURCE_COUNT_STAT_ORDER; } function getCpuHotspotProfiler(device: Device): CpuHotspotProfiler | null { diff --git a/modules/core/src/adapter/resources/shared-render-pipeline.ts b/modules/core/src/adapter/resources/shared-render-pipeline.ts new file mode 100644 index 0000000000..695e1bc0c6 --- /dev/null +++ b/modules/core/src/adapter/resources/shared-render-pipeline.ts @@ -0,0 +1,24 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Device} from '../device'; +import {Resource, type ResourceProps} from './resource'; + +/** + * Internal base class for backend-specific shared render-pipeline implementations. + * Backends may use this to share expensive linked/program state across multiple + * `RenderPipeline` wrappers. + */ +export abstract class SharedRenderPipeline extends Resource { + override get [Symbol.toStringTag](): string { + return 'SharedRenderPipeline'; + } + + abstract override readonly device: Device; + abstract override readonly handle: unknown; + + constructor(device: Device, props: ResourceProps = {}) { + super(device, props, Resource.defaultProps); + } +} diff --git a/modules/core/src/index.ts b/modules/core/src/index.ts index faf2c2af24..2bf8407316 100644 --- a/modules/core/src/index.ts +++ b/modules/core/src/index.ts @@ -45,6 +45,7 @@ export {Framebuffer} from './adapter/resources/framebuffer'; export type {RenderPipelineProps} from './adapter/resources/render-pipeline'; export {RenderPipeline} from './adapter/resources/render-pipeline'; +export {SharedRenderPipeline} from './adapter/resources/shared-render-pipeline'; export type {RenderPassProps} from './adapter/resources/render-pass'; export {RenderPass} from './adapter/resources/render-pass'; diff --git a/modules/engine/src/factories/pipeline-factory.ts b/modules/engine/src/factories/pipeline-factory.ts index e56dc52e37..d90d2e34b0 100644 --- a/modules/engine/src/factories/pipeline-factory.ts +++ b/modules/engine/src/factories/pipeline-factory.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import type {RenderPipelineProps, ComputePipelineProps} from '@luma.gl/core'; +import type {RenderPipelineProps, ComputePipelineProps, SharedRenderPipeline} from '@luma.gl/core'; import {Device, RenderPipeline, ComputePipeline, log} from '@luma.gl/core'; import type {EngineModuleState} from '../types'; import {uid} from '../utils/uid'; @@ -11,6 +11,11 @@ export type PipelineFactoryProps = RenderPipelineProps; type RenderPipelineCacheItem = {pipeline: RenderPipeline; useCount: number}; type ComputePipelineCacheItem = {pipeline: ComputePipeline; useCount: number}; +type SharedRenderPipelineCacheItem = {sharedRenderPipeline: SharedRenderPipeline; useCount: number}; +type WebGLRenderPipelineCacheProps = RenderPipelineProps & { + varyings?: string[]; + bufferMode?: number; +}; /** * Efficiently creates / caches pipelines @@ -27,6 +32,7 @@ export class PipelineFactory { readonly device: Device; readonly cachingEnabled: boolean; + readonly sharingEnabled: boolean; readonly destroyPolicy: 'unused' | 'never'; readonly debug: boolean; @@ -34,6 +40,7 @@ export class PipelineFactory { private readonly _hashes: Record = {}; private readonly _renderPipelineCache: Record = {}; private readonly _computePipelineCache: Record = {}; + private readonly _sharedRenderPipelineCache: Record = {}; get [Symbol.toStringTag](): string { return 'PipelineFactory'; @@ -46,7 +53,8 @@ export class PipelineFactory { constructor(device: Device) { this.device = device; this.cachingEnabled = device.props._cachePipelines; - this.destroyPolicy = device.props._cacheDestroyPolicy; + this.sharingEnabled = device.props._sharePipelines; + this.destroyPolicy = device.props._destroyPipelines ? 'unused' : 'never'; this.debug = device.props.debugFactories; } @@ -63,11 +71,19 @@ export class PipelineFactory { let pipeline: RenderPipeline = cache[hash]?.pipeline; if (!pipeline) { + const implementationHash = this._shouldShareRenderPipelines() + ? this._hashSharedRenderPipeline(allProps) + : ''; + const sharedRenderPipeline = this._shouldShareRenderPipelines() + ? this._getOrCreateSharedRenderPipeline(allProps, implementationHash) + : undefined; pipeline = this.device.createRenderPipeline({ ...allProps, - id: allProps.id ? `${allProps.id}-cached` : uid('unnamed-cached') + id: allProps.id ? `${allProps.id}-cached` : uid('unnamed-cached'), + _sharedRenderPipeline: sharedRenderPipeline }); pipeline.hash = hash; + pipeline.implementationHash = implementationHash; cache[hash] = {pipeline, useCount: 1}; if (this.debug) { log.log(3, `${this}: ${pipeline} created, count=${cache[hash].useCount}`)(); @@ -155,6 +171,18 @@ export class PipelineFactory { case 'unused': delete cache[pipeline.hash]; pipeline.destroy(); + if ( + pipeline instanceof RenderPipeline && + pipeline.implementationHash && + this._sharedRenderPipelineCache[pipeline.implementationHash] + ) { + const sharedCacheItem = this._sharedRenderPipelineCache[pipeline.implementationHash]; + sharedCacheItem.useCount--; + if (sharedCacheItem.useCount === 0) { + sharedCacheItem.sharedRenderPipeline.destroy(); + delete this._sharedRenderPipelineCache[pipeline.implementationHash]; + } + } return true; } } @@ -193,19 +221,16 @@ export class PipelineFactory { private _hashRenderPipeline(props: RenderPipelineProps): string { const vsHash = props.vs ? this._getHash(props.vs.source) : 0; const fsHash = props.fs ? this._getHash(props.fs.source) : 0; - - // WebGL specific - // const {varyings = [], bufferMode = {}} = props; - // const varyingHashes = varyings.map((v) => this._getHash(v)); - const varyingHash = '-'; // `${varyingHashes.join('/')}B${bufferMode}` + const varyingHash = this._getWebGLVaryingHash(props); const bufferLayoutHash = this._getHash(JSON.stringify(props.bufferLayout)); const {type} = this.device; switch (type) { case 'webgl': - // WebGL is more dynamic but still needs to avoid sharing pipelines when render parameters differ + // WebGL wrappers preserve default topology and parameter semantics for direct + // callers, even though the underlying linked program may be shared separately. const webglParameterHash = this._getHash(JSON.stringify(props.parameters)); - return `${type}/R/${vsHash}/${fsHash}V${varyingHash}P${webglParameterHash}BL${bufferLayoutHash}`; + return `${type}/R/${vsHash}/${fsHash}V${varyingHash}T${props.topology}P${webglParameterHash}BL${bufferLayoutHash}`; case 'webgpu': default: @@ -217,10 +242,40 @@ export class PipelineFactory { } } + private _hashSharedRenderPipeline(props: RenderPipelineProps): string { + const vsHash = props.vs ? this._getHash(props.vs.source) : 0; + const fsHash = props.fs ? this._getHash(props.fs.source) : 0; + const varyingHash = this._getWebGLVaryingHash(props); + return `webgl/S/${vsHash}/${fsHash}V${varyingHash}`; + } + + private _getOrCreateSharedRenderPipeline( + props: RenderPipelineProps, + implementationHash: string + ): SharedRenderPipeline { + let sharedCacheItem = this._sharedRenderPipelineCache[implementationHash]; + if (!sharedCacheItem) { + const sharedRenderPipeline = this.device._createSharedRenderPipelineWebGL(props); + sharedCacheItem = {sharedRenderPipeline, useCount: 0}; + this._sharedRenderPipelineCache[implementationHash] = sharedCacheItem; + } + sharedCacheItem.useCount++; + return sharedCacheItem.sharedRenderPipeline; + } + private _getHash(key: string): number { if (this._hashes[key] === undefined) { this._hashes[key] = this._hashCounter++; } return this._hashes[key]; } + + private _shouldShareRenderPipelines(): boolean { + return this.device.type === 'webgl' && this.sharingEnabled; + } + + private _getWebGLVaryingHash(props: RenderPipelineProps): number { + const {varyings = [], bufferMode = null} = props as WebGLRenderPipelineCacheProps; + return this._getHash(JSON.stringify({varyings, bufferMode})); + } } diff --git a/modules/engine/src/factories/shader-factory.ts b/modules/engine/src/factories/shader-factory.ts index 098ec5ac5f..3877539d1e 100644 --- a/modules/engine/src/factories/shader-factory.ts +++ b/modules/engine/src/factories/shader-factory.ts @@ -35,7 +35,7 @@ export class ShaderFactory { constructor(device: Device) { this.device = device; this.cachingEnabled = device.props._cacheShaders; - this.destroyPolicy = device.props._cacheDestroyPolicy; + this.destroyPolicy = device.props._destroyShaders ? 'unused' : 'never'; this.debug = true; // device.props.debugFactories; } diff --git a/modules/engine/src/model/model.ts b/modules/engine/src/model/model.ts index e212d610a7..35605019fe 100644 --- a/modules/engine/src/model/model.ts +++ b/modules/engine/src/model/model.ts @@ -441,6 +441,9 @@ export class Model { instanceCount: this.instanceCount, indexCount, transformFeedback: this.transformFeedback || undefined, + // Pipelines may be shared across models when caching is enabled, so bindings + // and WebGL uniforms must be supplied on every draw instead of being stored + // on the pipeline instance. bindings: syncBindings, uniforms: this.props.uniforms, // WebGL shares underlying cached pipelines even for models that have different parameters and topology, diff --git a/modules/engine/test/lib/pipeline-factory.spec.ts b/modules/engine/test/lib/pipeline-factory.spec.ts index cb6a23a961..a219952bdc 100644 --- a/modules/engine/test/lib/pipeline-factory.spec.ts +++ b/modules/engine/test/lib/pipeline-factory.spec.ts @@ -5,9 +5,9 @@ import test from 'tape-promise/tape'; import {getWebGLTestDevice} from '@luma.gl/test-utils'; +import {luma} from '@luma.gl/core'; import {PipelineFactory} from '@luma.gl/engine'; - -// TODO - this doesn't test that parameters etc are properly cached +import {webgl2Adapter, type WebGLDevice} from '@luma.gl/webgl'; const vsSource = /* glsl */ `\ in vec4 positions; @@ -25,6 +25,50 @@ void main(void) { } `; +const transformVsSource = /* glsl */ `\ +#version 300 es +in float inValue; +out float outValue; +out float otherValue; +void main() +{ + outValue = 2.0 * inValue; + otherValue = 3.0 * inValue; + gl_Position = vec4(inValue, 0.0, 0.0, 1.0); +} +`; + +const transformFsSource = /* glsl */ `\ +#version 300 es +precision highp float; +in float outValue; +out vec4 fragmentColor; +void main() +{ + fragmentColor = vec4(outValue, 0.0, 0.0, 1.0); +} +`; + +const alternateVsSource = /* glsl */ `\ +in vec4 positions; + +void main(void) { + gl_Position = positions + vec4(0.0, 0.0, 0.0, 0.0); +} +`; + +function isProgramDestroyed(handle: WebGLProgram): boolean { + return Boolean((handle as WebGLProgram & {destroyed?: boolean}).destroyed); +} + +function getSharedRenderPipelineCount( + webglDevice: Awaited> +): number { + return webglDevice.statsManager + .getStats('GPU Resource Counts') + .get('SharedRenderPipelines Active').count; +} + test('PipelineFactory#import', t => { t.ok(PipelineFactory !== undefined, 'PipelineFactory import successful'); t.end(); @@ -51,6 +95,7 @@ test('PipelineFactory#release', async t => { } const pipelineFactory = new PipelineFactory(webglDevice); + const initialSharedRenderPipelineCount = getSharedRenderPipelineCount(webglDevice); const vs = webglDevice.createShader({stage: 'vertex', source: vsSource}); const fs = webglDevice.createShader({stage: 'fragment', source: fsSource}); @@ -101,11 +146,261 @@ test('PipelineFactory#caching with parameters', async t => { topology: 'triangle-list', parameters: paramsB }); - t.notEqual(pipeline1, pipeline3, 'Does not cache pipelines with different parameters'); + t.notEqual(pipeline1, pipeline3, 'Does not cache wrapper pipelines with different parameters'); + t.equal( + pipeline1.sharedRenderPipeline, + pipeline3.sharedRenderPipeline, + 'Reuses the shared WebGL render pipeline record' + ); + t.equal( + getSharedRenderPipelineCount(webglDevice), + initialSharedRenderPipelineCount + 1, + 'Tracks one active shared render pipeline for compatible WebGL wrappers' + ); + t.equal(pipeline1.handle, pipeline3.handle, 'Reuses the underlying WebGLProgram'); pipelineFactory.release(pipeline1); pipelineFactory.release(pipeline2); + t.notOk( + isProgramDestroyed(pipeline3.handle), + 'Shared program remains alive while another wrapper uses it' + ); pipelineFactory.release(pipeline3); + t.ok( + isProgramDestroyed(pipeline3.handle), + 'Shared program is deleted after the last wrapper is released' + ); + t.ok( + pipeline3.sharedRenderPipeline?.destroyed, + 'Shared render pipeline resource is deleted after the last wrapper is released' + ); + t.equal( + getSharedRenderPipelineCount(webglDevice), + initialSharedRenderPipelineCount, + 'Shared render pipeline resource count returns to baseline after release' + ); + + t.end(); +}); + +test('PipelineFactory#caching with topology on webgl', async t => { + const webglDevice = await getWebGLTestDevice(); + if (!webglDevice.props._cachePipelines) { + t.comment('Pipeline caching not enabled'); + t.end(); + return; + } + + const pipelineFactory = new PipelineFactory(webglDevice); + + const vs = webglDevice.createShader({stage: 'vertex', source: vsSource}); + const fs = webglDevice.createShader({stage: 'fragment', source: fsSource}); + + const trianglePipeline = pipelineFactory.createRenderPipeline({ + vs, + fs, + topology: 'triangle-list' + }); + const linePipeline = pipelineFactory.createRenderPipeline({ + vs, + fs, + topology: 'line-strip' + }); + + t.notEqual( + trianglePipeline, + linePipeline, + 'Does not cache wrapper pipelines with different topology' + ); + t.equal( + trianglePipeline.handle, + linePipeline.handle, + 'Reuses the underlying WebGLProgram across topology variants' + ); + + pipelineFactory.release(trianglePipeline); + pipelineFactory.release(linePipeline); + + t.end(); +}); + +test('PipelineFactory#caching with bufferLayout on webgl', async t => { + const webglDevice = await getWebGLTestDevice(); + if (!webglDevice.props._cachePipelines) { + t.comment('Pipeline caching not enabled'); + t.end(); + return; + } + + const pipelineFactory = new PipelineFactory(webglDevice); + + const vs = webglDevice.createShader({stage: 'vertex', source: vsSource}); + const fs = webglDevice.createShader({stage: 'fragment', source: fsSource}); + + const trianglePipeline = pipelineFactory.createRenderPipeline({ + vs, + fs, + topology: 'triangle-list', + bufferLayout: [{name: 'positions', format: 'float32x3'}] + }); + const interleavedPipeline = pipelineFactory.createRenderPipeline({ + vs, + fs, + topology: 'triangle-list', + bufferLayout: [{name: 'positions', format: 'float32x4'}] + }); + + t.notEqual( + trianglePipeline, + interleavedPipeline, + 'Does not cache wrapper pipelines with different buffer layouts' + ); + t.equal( + trianglePipeline.handle, + interleavedPipeline.handle, + 'Reuses the underlying WebGLProgram across buffer layout variants' + ); + + pipelineFactory.release(trianglePipeline); + pipelineFactory.release(interleavedPipeline); + + t.end(); +}); + +test('PipelineFactory#shared WebGL program cache is keyed by shader identity', async t => { + const webglDevice = await getWebGLTestDevice(); + if (!webglDevice.props._cachePipelines) { + t.comment('Pipeline caching not enabled'); + t.end(); + return; + } + + const pipelineFactory = new PipelineFactory(webglDevice); + + const vs1 = webglDevice.createShader({stage: 'vertex', source: vsSource}); + const vs2 = webglDevice.createShader({stage: 'vertex', source: alternateVsSource}); + const fs = webglDevice.createShader({stage: 'fragment', source: fsSource}); + + const pipeline1 = pipelineFactory.createRenderPipeline({vs: vs1, fs, topology: 'triangle-list'}); + const pipeline2 = pipelineFactory.createRenderPipeline({vs: vs2, fs, topology: 'triangle-list'}); + + t.notEqual( + pipeline1.sharedRenderPipeline, + pipeline2.sharedRenderPipeline, + 'Does not share WebGL programs across different shader sources' + ); + t.notEqual( + pipeline1.handle, + pipeline2.handle, + 'Creates a distinct WebGLProgram when shaders differ' + ); + + pipelineFactory.release(pipeline1); + pipelineFactory.release(pipeline2); + + t.end(); +}); + +test('PipelineFactory#sharing can be disabled independently from wrapper caching', async t => { + let webglDevice: WebGLDevice | null = null; + try { + webglDevice = (await luma.createDevice({ + id: 'webgl-test-device-no-sharing', + type: 'webgl', + adapters: [webgl2Adapter], + createCanvasContext: {width: 1, height: 1}, + debug: true, + _cachePipelines: true, + _sharePipelines: false + })) as WebGLDevice; + + const pipelineFactory = new PipelineFactory(webglDevice); + + const vs = webglDevice.createShader({stage: 'vertex', source: vsSource}); + const fs = webglDevice.createShader({stage: 'fragment', source: fsSource}); + + const pipeline1 = pipelineFactory.createRenderPipeline({ + vs, + fs, + topology: 'triangle-list', + parameters: {cullMode: 'back'} + }); + const pipeline2 = pipelineFactory.createRenderPipeline({ + vs, + fs, + topology: 'triangle-list', + parameters: {cullMode: 'front'} + }); + + t.notEqual( + pipeline1, + pipeline2, + 'Still creates distinct wrapper pipelines when parameters differ' + ); + t.equal( + pipeline1.sharedRenderPipeline, + null, + 'Does not attach a shared render pipeline when sharing is disabled' + ); + t.equal( + pipeline2.sharedRenderPipeline, + null, + 'Does not attach a shared render pipeline when sharing is disabled' + ); + t.notEqual( + pipeline1.handle, + pipeline2.handle, + 'Does not share the underlying WebGLProgram when sharing is disabled' + ); + + pipelineFactory.release(pipeline1); + pipelineFactory.release(pipeline2); + } finally { + webglDevice?.destroy(); + } + + t.end(); +}); + +test('PipelineFactory#shared WebGL program cache is keyed by transform feedback varyings', async t => { + const webglDevice = await getWebGLTestDevice(); + if (!webglDevice.props._cachePipelines) { + t.comment('Pipeline caching not enabled'); + t.end(); + return; + } + + const pipelineFactory = new PipelineFactory(webglDevice); + + const vs = webglDevice.createShader({stage: 'vertex', source: transformVsSource}); + const fs = webglDevice.createShader({stage: 'fragment', source: transformFsSource}); + + const pipeline1 = pipelineFactory.createRenderPipeline({ + vs, + fs, + topology: 'point-list', + varyings: ['outValue'] + } as any); + const pipeline2 = pipelineFactory.createRenderPipeline({ + vs, + fs, + topology: 'point-list', + varyings: ['otherValue'] + } as any); + + t.notEqual( + pipeline1.sharedRenderPipeline, + pipeline2.sharedRenderPipeline, + 'Does not share WebGL programs across different transform feedback varying sets' + ); + t.notEqual( + pipeline1.handle, + pipeline2.handle, + 'Creates distinct WebGLPrograms when transform feedback varyings differ' + ); + + pipelineFactory.release(pipeline1); + pipelineFactory.release(pipeline2); t.end(); }); diff --git a/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts b/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts index cf4d9f1d5b..5f4f732ed7 100644 --- a/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts +++ b/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts @@ -16,8 +16,6 @@ import {RenderPipeline, log} from '@luma.gl/core'; // import {getAttributeInfosFromLayouts} from '@luma.gl/core'; import {GL} from '@luma.gl/constants'; -import {getShaderLayoutFromGLSL} from '../helpers/get-shader-layout-from-glsl'; -import {isGLSamplerType} from '../converters/webgl-shadertypes'; import {withDeviceAndGLParameters} from '../converters/device-parameters'; import {setUniform} from '../helpers/set-uniform'; // import {copyUniform, checkUniformValues} from '../../classes/uniforms'; @@ -31,8 +29,7 @@ import {WEBGLTextureView} from './webgl-texture-view'; import {WEBGLRenderPass} from './webgl-render-pass'; import {WEBGLTransformFeedback} from './webgl-transform-feedback'; import {getGLDrawMode} from '../helpers/webgl-topology-utils'; - -const LOG_PROGRAM_PERF_PRIORITY = 4; +import {WEBGLSharedRenderPipeline} from './webgl-shared-render-pipeline'; /** Creates a new render pipeline */ export class WEBGLRenderPipeline extends RenderPipeline { @@ -47,6 +44,10 @@ export class WEBGLRenderPipeline extends RenderPipeline { /** The layout extracted from shader by WebGL introspection APIs */ introspectedLayout: ShaderLayout; + /** Compatibility path for direct pipeline.setBindings() usage */ + bindings: Record = {}; + /** Compatibility path for direct pipeline.uniforms usage */ + uniforms: Record = {}; /** WebGL varyings */ varyings: string[] | null = null; @@ -60,28 +61,18 @@ export class WEBGLRenderPipeline extends RenderPipeline { constructor(device: WebGLDevice, props: RenderPipelineProps) { super(device, props); this.device = device; - this.handle = this.props.handle || this.device.gl.createProgram(); + const webglSharedRenderPipeline = + (this.sharedRenderPipeline as WEBGLSharedRenderPipeline | null) || + (this.device._createSharedRenderPipelineWebGL(props) as WEBGLSharedRenderPipeline); + + this.sharedRenderPipeline = webglSharedRenderPipeline; + this.handle = webglSharedRenderPipeline.handle; + this.vs = webglSharedRenderPipeline.vs; + this.fs = webglSharedRenderPipeline.fs; + this.linkStatus = webglSharedRenderPipeline.linkStatus; + this.introspectedLayout = webglSharedRenderPipeline.introspectedLayout; this.device._setWebGLDebugMetadata(this.handle, this, {spector: {id: this.props.id}}); - // Create shaders if needed - this.vs = props.vs as WEBGLShader; - this.fs = props.fs as WEBGLShader; - // assert(this.vs.stage === 'vertex'); - // assert(this.fs.stage === 'fragment'); - - // Setup varyings if supplied - // @ts-expect-error WebGL only - const {varyings, bufferMode = GL.SEPARATE_ATTRIBS} = props; - if (varyings && varyings.length > 0) { - this.varyings = varyings; - this.device.gl.transformFeedbackVaryings(this.handle, varyings, bufferMode); - } - - this._linkShaders(); - log.time(3, `RenderPipeline ${this.id} - shaderLayout introspection`)(); - this.introspectedLayout = getShaderLayoutFromGLSL(this.device.gl, this.handle); - log.timeEnd(3, `RenderPipeline ${this.id} - shaderLayout introspection`)(); - // Merge provided layout with introspected layout this.shaderLayout = props.shaderLayout ? mergeShaderLayout(this.introspectedLayout, props.shaderLayout) @@ -89,15 +80,63 @@ export class WEBGLRenderPipeline extends RenderPipeline { } override destroy(): void { - if (this.handle) { - // log.error(`Deleting program ${this.id}`)(); - this.device.gl.useProgram(null); - this.device.gl.deleteProgram(this.handle); - this.destroyed = true; - // @ts-expect-error - this.handle.destroyed = true; - // @ts-ignore - this.handle = null; + if (this.destroyed) { + return; + } + this.destroyResource(); + } + + /** + * Compatibility shim for code paths that still set bindings on the pipeline. + * Shared-model draws pass bindings per draw and do not rely on this state. + */ + setBindings(bindings: Record, options?: {disableWarnings?: boolean}): void { + for (const [name, value] of Object.entries(bindings)) { + const binding = + this.shaderLayout.bindings.find(binding_ => binding_.name === name) || + this.shaderLayout.bindings.find(binding_ => binding_.name === `${name}Uniforms`); + + if (!binding) { + const validBindings = this.shaderLayout.bindings + .map(binding_ => `"${binding_.name}"`) + .join(', '); + if (!options?.disableWarnings) { + log.warn( + `No binding "${name}" in render pipeline "${this.id}", expected one of ${validBindings}`, + value + )(); + } + continue; + } + if (!value) { + log.warn(`Unsetting binding "${name}" in render pipeline "${this.id}"`)(); + } + switch (binding.type) { + case 'uniform': + // @ts-expect-error + if (!(value instanceof WEBGLBuffer) && !(value.buffer instanceof WEBGLBuffer)) { + throw new Error('buffer value'); + } + break; + case 'texture': + if ( + !( + value instanceof WEBGLTextureView || + value instanceof WEBGLTexture || + value instanceof WEBGLFramebuffer + ) + ) { + throw new Error(`${this} Bad texture binding for ${name}`); + } + break; + case 'sampler': + log.warn(`Ignoring sampler ${name}`)(); + break; + default: + throw new Error(binding.type); + } + + this.bindings[name] = value; } } @@ -122,6 +161,8 @@ export class WEBGLRenderPipeline extends RenderPipeline { bindings?: Record; uniforms?: Record; }): boolean { + this._syncLinkStatus(); + const { renderPass, parameters = this.props.parameters, @@ -136,8 +177,8 @@ export class WEBGLRenderPipeline extends RenderPipeline { // firstInstance, // baseVertex, transformFeedback, - bindings = {}, - uniforms = {} + bindings = this.bindings, + uniforms = this.uniforms } = options; const glDrawMode = getGLDrawMode(topology); @@ -217,167 +258,6 @@ export class WEBGLRenderPipeline extends RenderPipeline { return true; } - // PRIVATE METHODS - - // setAttributes(attributes: Record): void {} - // setBindings(bindings: Record): void {} - - protected async _linkShaders() { - const {gl} = this.device; - gl.attachShader(this.handle, this.vs.handle); - gl.attachShader(this.handle, this.fs.handle); - log.time(LOG_PROGRAM_PERF_PRIORITY, `linkProgram for ${this.id}`)(); - gl.linkProgram(this.handle); - log.timeEnd(LOG_PROGRAM_PERF_PRIORITY, `linkProgram for ${this.id}`)(); - - // TODO Avoid checking program linking error in production - if (log.level === 0) { - // return; - } - - if (!this.device.features.has('compilation-status-async-webgl')) { - const status = this._getLinkStatus(); - this._reportLinkStatus(status); - return; - } - - // async case - log.once(1, 'RenderPipeline linking is asynchronous')(); - await this._waitForLinkComplete(); - log.info(2, `RenderPipeline ${this.id} - async linking complete: ${this.linkStatus}`)(); - const status = this._getLinkStatus(); - this._reportLinkStatus(status); - } - - /** Report link status. First, check for shader compilation failures if linking fails */ - async _reportLinkStatus(status: 'success' | 'link-error' | 'validation-error'): Promise { - switch (status) { - case 'success': - return; - - default: - const errorType = status === 'link-error' ? 'Link error' : 'Validation error'; - // First check for shader compilation failures if linking fails - switch (this.vs.compilationStatus) { - case 'error': - this.vs.debugShader(); - throw new Error(`${this} ${errorType} during compilation of ${this.vs}`); - case 'pending': - await this.vs.asyncCompilationStatus; - this.vs.debugShader(); - break; - case 'success': - break; - } - - switch (this.fs?.compilationStatus) { - case 'error': - this.fs.debugShader(); - throw new Error(`${this} ${errorType} during compilation of ${this.fs}`); - case 'pending': - await this.fs.asyncCompilationStatus; - this.fs.debugShader(); - break; - case 'success': - break; - } - - const linkErrorLog = this.device.gl.getProgramInfoLog(this.handle); - this.device.reportError( - new Error(`${errorType} during ${status}: ${linkErrorLog}`), - this - )(); - this.device.debug(); - } - } - - /** - * Get the shader compilation status - * TODO - Load log even when no error reported, to catch warnings? - * https://gamedev.stackexchange.com/questions/30429/how-to-detect-glsl-warnings - */ - _getLinkStatus(): 'success' | 'link-error' | 'validation-error' { - const {gl} = this.device; - const linked = gl.getProgramParameter(this.handle, GL.LINK_STATUS); - if (!linked) { - this.linkStatus = 'error'; - return 'link-error'; - } - - this._initializeSamplerUniforms(); - gl.validateProgram(this.handle); - const validated = gl.getProgramParameter(this.handle, GL.VALIDATE_STATUS); - if (!validated) { - this.linkStatus = 'error'; - return 'validation-error'; - } - - this.linkStatus = 'success'; - return 'success'; - } - - _initializeSamplerUniforms(): void { - const {gl} = this.device; - gl.useProgram(this.handle); - - let textureUnit = 0; - const uniformCount = gl.getProgramParameter(this.handle, GL.ACTIVE_UNIFORMS); - for (let uniformIndex = 0; uniformIndex < uniformCount; uniformIndex++) { - const activeInfo = gl.getActiveUniform(this.handle, uniformIndex); - if (activeInfo && isGLSamplerType(activeInfo.type)) { - const isArray = activeInfo.name.endsWith('[0]'); - const uniformName = isArray ? activeInfo.name.slice(0, -3) : activeInfo.name; - const location = gl.getUniformLocation(this.handle, uniformName); - - if (location !== null) { - textureUnit = this._assignSamplerUniform(location, activeInfo, isArray, textureUnit); - } - } - } - } - - _assignSamplerUniform( - location: WebGLUniformLocation, - activeInfo: WebGLActiveInfo, - isArray: boolean, - textureUnit: number - ): number { - const {gl} = this.device; - - if (isArray && activeInfo.size > 1) { - const textureUnits = Int32Array.from( - {length: activeInfo.size}, - (_, arrayIndex) => textureUnit + arrayIndex - ); - gl.uniform1iv(location, textureUnits); - return textureUnit + activeInfo.size; - } - - gl.uniform1i(location, textureUnit); - return textureUnit + 1; - } - - /** Use KHR_parallel_shader_compile extension if available */ - async _waitForLinkComplete(): Promise { - const waitMs = async (ms: number) => await new Promise(resolve => setTimeout(resolve, ms)); - const DELAY_MS = 10; // Shader compilation is typically quite fast (with some exceptions) - - // If status polling is not available, we can't wait for completion. Just wait a little to minimize blocking - if (!this.device.features.has('compilation-status-async-webgl')) { - await waitMs(DELAY_MS); - return; - } - - const {gl} = this.device; - for (;;) { - const complete = gl.getProgramParameter(this.handle, GL.COMPLETION_STATUS_KHR); - if (complete) { - return; - } - await waitMs(DELAY_MS); - } - } - /** * Checks if all texture-values uniforms are renderable (i.e. loaded) * Update a texture if needed (e.g. from video) @@ -405,6 +285,8 @@ export class WEBGLRenderPipeline extends RenderPipeline { /** Apply any bindings (before each draw call) */ _applyBindings(bindings: Record, _options?: {disableWarnings?: boolean}) { + this._syncLinkStatus(); + // If we are using async linking, we need to wait until linking completes if (this.linkStatus !== 'success') { return; @@ -504,6 +386,10 @@ export class WEBGLRenderPipeline extends RenderPipeline { } } } + + private _syncLinkStatus(): void { + this.linkStatus = (this.sharedRenderPipeline as WEBGLSharedRenderPipeline).linkStatus; + } } /** diff --git a/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts b/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts new file mode 100644 index 0000000000..614f2aadec --- /dev/null +++ b/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts @@ -0,0 +1,206 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {GL} from '@luma.gl/constants'; +import {SharedRenderPipeline, log, type ResourceProps, type ShaderLayout} from '@luma.gl/core'; + +import {getShaderLayoutFromGLSL} from '../helpers/get-shader-layout-from-glsl'; +import {isGLSamplerType} from '../converters/webgl-shadertypes'; +import type {WebGLDevice} from '../webgl-device'; +import type {WEBGLShader} from './webgl-shader'; + +const LOG_PROGRAM_PERF_PRIORITY = 4; + +export class WEBGLSharedRenderPipeline extends SharedRenderPipeline { + readonly device: WebGLDevice; + readonly handle: WebGLProgram; + readonly vs: WEBGLShader; + readonly fs: WEBGLShader; + introspectedLayout: ShaderLayout = {attributes: [], bindings: [], uniforms: []}; + linkStatus: 'pending' | 'success' | 'error' = 'pending'; + + override get [Symbol.toStringTag](): string { + return 'WEBGLSharedRenderPipeline'; + } + + constructor( + device: WebGLDevice, + props: ResourceProps & { + handle?: WebGLProgram; + vs: WEBGLShader; + fs: WEBGLShader; + varyings?: string[]; + bufferMode?: number; + } + ) { + super(device, props); + this.device = device; + this.handle = props.handle || this.device.gl.createProgram(); + this.vs = props.vs; + this.fs = props.fs; + + if (props.varyings && props.varyings.length > 0) { + this.device.gl.transformFeedbackVaryings( + this.handle, + props.varyings, + props.bufferMode || GL.SEPARATE_ATTRIBS + ); + } + + this._linkShaders(); + log.time(3, `RenderPipeline ${this.id} - shaderLayout introspection`)(); + this.introspectedLayout = getShaderLayoutFromGLSL(this.device.gl, this.handle); + log.timeEnd(3, `RenderPipeline ${this.id} - shaderLayout introspection`)(); + } + + override destroy(): void { + if (this.destroyed) { + return; + } + + this.device.gl.useProgram(null); + this.device.gl.deleteProgram(this.handle); + // @ts-expect-error + this.handle.destroyed = true; + this.destroyResource(); + } + + protected async _linkShaders() { + const {gl} = this.device; + gl.attachShader(this.handle, this.vs.handle); + gl.attachShader(this.handle, this.fs.handle); + log.time(LOG_PROGRAM_PERF_PRIORITY, `linkProgram for ${this.id}`)(); + gl.linkProgram(this.handle); + log.timeEnd(LOG_PROGRAM_PERF_PRIORITY, `linkProgram for ${this.id}`)(); + + if (!this.device.features.has('compilation-status-async-webgl')) { + const status = this._getLinkStatus(); + this._reportLinkStatus(status); + return; + } + + log.once(1, 'RenderPipeline linking is asynchronous')(); + await this._waitForLinkComplete(); + log.info(2, `RenderPipeline ${this.id} - async linking complete: ${this.linkStatus}`)(); + const status = this._getLinkStatus(); + this._reportLinkStatus(status); + } + + async _reportLinkStatus(status: 'success' | 'link-error' | 'validation-error'): Promise { + switch (status) { + case 'success': + return; + + default: + const errorType = status === 'link-error' ? 'Link error' : 'Validation error'; + switch (this.vs.compilationStatus) { + case 'error': + this.vs.debugShader(); + throw new Error(`${this} ${errorType} during compilation of ${this.vs}`); + case 'pending': + await this.vs.asyncCompilationStatus; + this.vs.debugShader(); + break; + case 'success': + break; + } + + switch (this.fs?.compilationStatus) { + case 'error': + this.fs.debugShader(); + throw new Error(`${this} ${errorType} during compilation of ${this.fs}`); + case 'pending': + await this.fs.asyncCompilationStatus; + this.fs.debugShader(); + break; + case 'success': + break; + } + + const linkErrorLog = this.device.gl.getProgramInfoLog(this.handle); + this.device.reportError(new Error(`${errorType} during ${status}: ${linkErrorLog}`), this)(); + this.device.debug(); + } + } + + _getLinkStatus(): 'success' | 'link-error' | 'validation-error' { + const {gl} = this.device; + const linked = gl.getProgramParameter(this.handle, GL.LINK_STATUS); + if (!linked) { + this.linkStatus = 'error'; + return 'link-error'; + } + + this._initializeSamplerUniforms(); + gl.validateProgram(this.handle); + const validated = gl.getProgramParameter(this.handle, GL.VALIDATE_STATUS); + if (!validated) { + this.linkStatus = 'error'; + return 'validation-error'; + } + + this.linkStatus = 'success'; + return 'success'; + } + + _initializeSamplerUniforms(): void { + const {gl} = this.device; + gl.useProgram(this.handle); + + let textureUnit = 0; + const uniformCount = gl.getProgramParameter(this.handle, GL.ACTIVE_UNIFORMS); + for (let uniformIndex = 0; uniformIndex < uniformCount; uniformIndex++) { + const activeInfo = gl.getActiveUniform(this.handle, uniformIndex); + if (activeInfo && isGLSamplerType(activeInfo.type)) { + const isArray = activeInfo.name.endsWith('[0]'); + const uniformName = isArray ? activeInfo.name.slice(0, -3) : activeInfo.name; + const location = gl.getUniformLocation(this.handle, uniformName); + + if (location !== null) { + textureUnit = this._assignSamplerUniform(location, activeInfo, isArray, textureUnit); + } + } + } + } + + _assignSamplerUniform( + location: WebGLUniformLocation, + activeInfo: WebGLActiveInfo, + isArray: boolean, + textureUnit: number + ): number { + const {gl} = this.device; + + if (isArray && activeInfo.size > 1) { + const textureUnits = Int32Array.from( + {length: activeInfo.size}, + (_, arrayIndex) => textureUnit + arrayIndex + ); + gl.uniform1iv(location, textureUnits); + return textureUnit + activeInfo.size; + } + + gl.uniform1i(location, textureUnit); + return textureUnit + 1; + } + + async _waitForLinkComplete(): Promise { + const waitMs = async (ms: number) => await new Promise(resolve => setTimeout(resolve, ms)); + const DELAY_MS = 10; + + if (!this.device.features.has('compilation-status-async-webgl')) { + await waitMs(DELAY_MS); + return; + } + + const {gl} = this.device; + for (;;) { + const complete = gl.getProgramParameter(this.handle, GL.COMPLETION_STATUS_KHR); + if (complete) { + return; + } + await waitMs(DELAY_MS); + } + } +} diff --git a/modules/webgl/src/adapter/webgl-device.ts b/modules/webgl/src/adapter/webgl-device.ts index 98b5c7e7e5..ea8fcde764 100644 --- a/modules/webgl/src/adapter/webgl-device.ts +++ b/modules/webgl/src/adapter/webgl-device.ts @@ -25,6 +25,7 @@ import type { FramebufferProps, // RenderPipeline, RenderPipelineProps, + SharedRenderPipeline, ComputePipeline, ComputePipelineProps, // CommandEncoder, @@ -56,6 +57,7 @@ import {WEBGLSampler} from './resources/webgl-sampler'; import {WEBGLTexture} from './resources/webgl-texture'; import {WEBGLFramebuffer} from './resources/webgl-framebuffer'; import {WEBGLRenderPipeline} from './resources/webgl-render-pipeline'; +import {WEBGLSharedRenderPipeline} from './resources/webgl-shared-render-pipeline'; import {WEBGLCommandEncoder} from './resources/webgl-command-encoder'; import {WEBGLCommandBuffer} from './resources/webgl-command-buffer'; import {WEBGLVertexArray} from './resources/webgl-vertex-array'; @@ -344,6 +346,13 @@ export class WebGLDevice extends Device { return new WEBGLRenderPipeline(this, props); } + override _createSharedRenderPipelineWebGL(props: RenderPipelineProps): SharedRenderPipeline { + return new WEBGLSharedRenderPipeline( + this, + props as RenderPipelineProps & {vs: WEBGLShader; fs: WEBGLShader} + ); + } + createComputePipeline(props?: ComputePipelineProps): ComputePipeline { throw new Error('ComputePipeline not supported in WebGL'); } diff --git a/modules/webgpu/src/adapter/helpers/generate-mipmaps-webgpu.ts b/modules/webgpu/src/adapter/helpers/generate-mipmaps-webgpu.ts index 88a828ae61..02a6c41ee5 100644 --- a/modules/webgpu/src/adapter/helpers/generate-mipmaps-webgpu.ts +++ b/modules/webgpu/src/adapter/helpers/generate-mipmaps-webgpu.ts @@ -201,11 +201,6 @@ function generateMipmapsRender(device: WebGPUDevice, texture: Texture): void { baseArrayLayer: 0, arrayLayerCount: texture.depth }); - renderPipeline.setBindings({ - sourceSampler: sampler, - sourceTexture: sourceView, - uniforms: uniformsBuffer - }); try { for (let baseArrayLayer = 0; baseArrayLayer < layerCount; ++baseArrayLayer) { diff --git a/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts b/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts index eca01e4a4c..3c3e4e9012 100644 --- a/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts +++ b/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts @@ -15,6 +15,8 @@ import type {WebGPUDevice} from '../webgpu-device'; import type {WebGPUShader} from './webgpu-shader'; import type {WebGPURenderPass} from './webgpu-render-pass'; +const EMPTY_BINDINGS: Record = {}; + // RENDER PIPELINE /** Creates a new render pipeline when parameters change */ @@ -25,8 +27,11 @@ export class WebGPURenderPipeline extends RenderPipeline { readonly vs: WebGPUShader; readonly fs: WebGPUShader | null = null; + /** Compatibility path for direct pipeline.setBindings() usage */ + private _bindings: Record; /** For internal use to create BindGroups */ private _bindGroupLayout: GPUBindGroupLayout | null = null; + private _bindGroup: GPUBindGroup | null = null; override get [Symbol.toStringTag]() { return 'WebGPURenderPipeline'; @@ -54,6 +59,7 @@ export class WebGPURenderPipeline extends RenderPipeline { // Note: Often the same shader in WebGPU this.vs = props.vs as WebGPUShader; this.fs = props.fs as WebGPUShader; + this._bindings = props.bindings || EMPTY_BINDINGS; } override destroy(): void { @@ -62,6 +68,28 @@ export class WebGPURenderPipeline extends RenderPipeline { this.handle = null; } + /** + * Compatibility shim for code paths that still set bindings on the pipeline. + * The shared-model path passes bindings per draw and does not rely on this state. + */ + setBindings(bindings: Record): void { + let bindingsChanged = false; + for (const [name, binding] of Object.entries(bindings)) { + if (this._bindings[name] !== binding) { + if (!bindingsChanged) { + if (this._bindings === this.props.bindings || this._bindings === EMPTY_BINDINGS) { + this._bindings = {...this._bindings}; + } + bindingsChanged = true; + } + this._bindings[name] = binding; + } + } + if (bindingsChanged) { + this._bindGroup = null; + } + } + /** @todo - should this be moved to renderpass? */ draw(options: { renderPass: RenderPass; @@ -89,7 +117,7 @@ export class WebGPURenderPipeline extends RenderPipeline { }); // Set bindings (uniform buffers, textures etc) - const bindGroup = this._getBindGroup(options.bindings || {}); + const bindGroup = this._getBindGroup(options.bindings); if (bindGroup) { webgpuRenderPass.handle.setBindGroup(0, bindGroup); } @@ -123,7 +151,7 @@ export class WebGPURenderPipeline extends RenderPipeline { } /** Return a bind group created by setBindings */ - _getBindGroup(bindings: Record) { + _getBindGroup(bindings?: Record) { if (this.shaderLayout.bindings.length === 0) { return null; } @@ -131,8 +159,15 @@ export class WebGPURenderPipeline extends RenderPipeline { // Get hold of the bind group layout. We don't want to do this unless we know there is at least one bind group this._bindGroupLayout = this._bindGroupLayout || this.handle.getBindGroupLayout(0); - // Set up the bindings - return getBindGroup(this.device, this._bindGroupLayout, this.shaderLayout, bindings); + if (bindings) { + return getBindGroup(this.device, this._bindGroupLayout, this.shaderLayout, bindings); + } + + this._bindGroup = + this._bindGroup || + getBindGroup(this.device, this._bindGroupLayout, this.shaderLayout, this._bindings); + + return this._bindGroup; } /** From 78a0d309cbaf23f831f594923c38f70e48616d5f Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 17 Mar 2026 08:19:15 -0400 Subject: [PATCH 09/12] wip --- modules/core/src/adapter/resources/fence.ts | 4 +- .../core/src/adapter/resources/resource.ts | 46 ++++++++++++++++--- .../engine/test/lib/pipeline-factory.spec.ts | 19 +++++--- .../resources/webgl-render-pipeline.ts | 3 ++ .../resources/webgl-shared-render-pipeline.ts | 9 ++-- 5 files changed, 63 insertions(+), 18 deletions(-) diff --git a/modules/core/src/adapter/resources/fence.ts b/modules/core/src/adapter/resources/fence.ts index accb161afb..fe4ef190f2 100644 --- a/modules/core/src/adapter/resources/fence.ts +++ b/modules/core/src/adapter/resources/fence.ts @@ -13,7 +13,9 @@ export abstract class Fence extends Resource { ...Resource.defaultProps }; - [Symbol.toStringTag]: string = 'WEBGLFence'; + override get [Symbol.toStringTag](): string { + return 'Fence'; + } /** Promise that resolves when the fence is signaled */ abstract readonly signaled: Promise; diff --git a/modules/core/src/adapter/resources/resource.ts b/modules/core/src/adapter/resources/resource.ts index e5456a4691..912f2a64cf 100644 --- a/modules/core/src/adapter/resources/resource.ts +++ b/modules/core/src/adapter/resources/resource.ts @@ -223,7 +223,7 @@ export abstract class Resource { for (const stats of statsObjects) { initializeStats(stats, orderedStatNames); } - const name = this[Symbol.toStringTag]; + const name = this.getStatsName(); for (const stats of statsObjects) { stats.get('Resources Active').decrementCount(); stats.get(`${name}s Active`).decrementCount(); @@ -236,7 +236,7 @@ export abstract class Resource { } /** Called by subclass to track memory allocations */ - protected trackAllocatedMemory(bytes: number, name = this[Symbol.toStringTag]): void { + protected trackAllocatedMemory(bytes: number, name = this.getStatsName()): void { const profiler = getCpuHotspotProfiler(this._device); const startTime = profiler ? getTimestamp() : 0; const stats = this._device.statsManager.getStats(GPU_TIME_AND_MEMORY_STATS); @@ -258,12 +258,12 @@ export abstract class Resource { } /** Called by subclass to track handle-backed memory allocations separately from owned allocations */ - protected trackReferencedMemory(bytes: number, name = this[Symbol.toStringTag]): void { + protected trackReferencedMemory(bytes: number, name = this.getStatsName()): void { this.trackAllocatedMemory(bytes, `Referenced ${name}`); } /** Called by subclass to track memory deallocations */ - protected trackDeallocatedMemory(name = this[Symbol.toStringTag]): void { + protected trackDeallocatedMemory(name = this.getStatsName()): void { if (this.allocatedBytes === 0) { this.allocatedBytesName = null; return; @@ -284,13 +284,13 @@ export abstract class Resource { } /** Called by subclass to deallocate handle-backed memory tracked via trackReferencedMemory() */ - protected trackDeallocatedReferencedMemory(name = this[Symbol.toStringTag]): void { + protected trackDeallocatedReferencedMemory(name = this.getStatsName()): void { this.trackDeallocatedMemory(`Referenced ${name}`); } /** Called by resource constructor to track object creation */ private addStats(): void { - const name = this[Symbol.toStringTag]; + const name = this.getStatsName(); const profiler = getCpuHotspotProfiler(this._device); const startTime = profiler ? getTimestamp() : 0; const statsObjects = [ @@ -314,6 +314,11 @@ export abstract class Resource { } recordTransientCanvasResourceCreate(this._device, name); } + + /** Canonical resource name used for stats buckets. */ + protected getStatsName(): string { + return getCanonicalResourceName(this); + } } /** @@ -419,3 +424,32 @@ function recordTransientCanvasResourceCreate(device: Device, name: string): void break; } } + +function getCanonicalResourceName(resource: Resource): string { + let prototype = Object.getPrototypeOf(resource); + + while (prototype) { + const parentPrototype = Object.getPrototypeOf(prototype); + if (!parentPrototype || parentPrototype === Resource.prototype) { + return ( + getPrototypeToStringTag(prototype) || + resource[Symbol.toStringTag] || + resource.constructor.name + ); + } + prototype = parentPrototype; + } + + return resource[Symbol.toStringTag] || resource.constructor.name; +} + +function getPrototypeToStringTag(prototype: object): string | null { + const descriptor = Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag); + if (typeof descriptor?.get === 'function') { + return descriptor.get.call(prototype); + } + if (typeof descriptor?.value === 'string') { + return descriptor.value; + } + return null; +} diff --git a/modules/engine/test/lib/pipeline-factory.spec.ts b/modules/engine/test/lib/pipeline-factory.spec.ts index a219952bdc..d879560e10 100644 --- a/modules/engine/test/lib/pipeline-factory.spec.ts +++ b/modules/engine/test/lib/pipeline-factory.spec.ts @@ -120,6 +120,7 @@ test('PipelineFactory#caching with parameters', async t => { } const pipelineFactory = new PipelineFactory(webglDevice); + const initialSharedRenderPipelineCount = getSharedRenderPipelineCount(webglDevice); const vs = webglDevice.createShader({stage: 'vertex', source: vsSource}); const fs = webglDevice.createShader({stage: 'fragment', source: fsSource}); @@ -315,6 +316,7 @@ test('PipelineFactory#sharing can be disabled independently from wrapper caching })) as WebGLDevice; const pipelineFactory = new PipelineFactory(webglDevice); + const initialSharedRenderPipelineCount = getSharedRenderPipelineCount(webglDevice); const vs = webglDevice.createShader({stage: 'vertex', source: vsSource}); const fs = webglDevice.createShader({stage: 'fragment', source: fsSource}); @@ -337,15 +339,15 @@ test('PipelineFactory#sharing can be disabled independently from wrapper caching pipeline2, 'Still creates distinct wrapper pipelines when parameters differ' ); - t.equal( + t.notEqual( pipeline1.sharedRenderPipeline, - null, - 'Does not attach a shared render pipeline when sharing is disabled' + pipeline2.sharedRenderPipeline, + 'Creates distinct shared render pipeline resources when sharing is disabled' ); t.equal( - pipeline2.sharedRenderPipeline, - null, - 'Does not attach a shared render pipeline when sharing is disabled' + getSharedRenderPipelineCount(webglDevice), + initialSharedRenderPipelineCount + 2, + 'Tracks separate shared render pipeline resources for each wrapper when sharing is disabled' ); t.notEqual( pipeline1.handle, @@ -355,6 +357,11 @@ test('PipelineFactory#sharing can be disabled independently from wrapper caching pipelineFactory.release(pipeline1); pipelineFactory.release(pipeline2); + t.equal( + getSharedRenderPipelineCount(webglDevice), + initialSharedRenderPipelineCount, + 'Shared render pipeline resource count returns to baseline after release' + ); } finally { webglDevice?.destroy(); } diff --git a/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts b/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts index 5f4f732ed7..7843e2f785 100644 --- a/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts +++ b/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts @@ -83,6 +83,9 @@ export class WEBGLRenderPipeline extends RenderPipeline { if (this.destroyed) { return; } + if (this.sharedRenderPipeline && !this.implementationHash) { + this.sharedRenderPipeline.destroy(); + } this.destroyResource(); } diff --git a/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts b/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts index 614f2aadec..8820f7e6da 100644 --- a/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts +++ b/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts @@ -20,10 +20,6 @@ export class WEBGLSharedRenderPipeline extends SharedRenderPipeline { introspectedLayout: ShaderLayout = {attributes: [], bindings: [], uniforms: []}; linkStatus: 'pending' | 'success' | 'error' = 'pending'; - override get [Symbol.toStringTag](): string { - return 'WEBGLSharedRenderPipeline'; - } - constructor( device: WebGLDevice, props: ResourceProps & { @@ -119,7 +115,10 @@ export class WEBGLSharedRenderPipeline extends SharedRenderPipeline { } const linkErrorLog = this.device.gl.getProgramInfoLog(this.handle); - this.device.reportError(new Error(`${errorType} during ${status}: ${linkErrorLog}`), this)(); + this.device.reportError( + new Error(`${errorType} during ${status}: ${linkErrorLog}`), + this + )(); this.device.debug(); } } From 8b09ae820e1db338ede7f39f53e29eaea55947fc Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 17 Mar 2026 09:53:51 -0400 Subject: [PATCH 10/12] cleanp --- docs/api-reference/core/device.md | 14 +- .../core/resources/render-pipeline.md | 41 ++--- docs/api-reference/engine/pipeline-factory.md | 9 +- modules/core/src/adapter/device.ts | 18 ++- .../src/adapter/resources/render-pipeline.ts | 9 +- .../resources/shared-render-pipeline.ts | 22 ++- modules/core/src/index.ts | 5 +- .../engine/src/factories/pipeline-factory.ts | 151 ++++++++---------- .../engine/src/factories/shader-factory.ts | 36 ++--- .../engine/test/lib/pipeline-factory.spec.ts | 1 - .../resources/webgl-render-pipeline.ts | 2 +- .../resources/webgl-shared-render-pipeline.ts | 11 +- 12 files changed, 173 insertions(+), 146 deletions(-) diff --git a/docs/api-reference/core/device.md b/docs/api-reference/core/device.md index 00ee02898d..8a1184cf1d 100644 --- a/docs/api-reference/core/device.md +++ b/docs/api-reference/core/device.md @@ -87,13 +87,13 @@ Learn more GPU debugging in our [Debugging](../../developer-guide/debugging.md) These props are primarily intended for internal tuning and testing of factory-managed resource reuse. -| Property | Default | Description | -| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------- | -| `_cacheShaders?: boolean` | `true` | Enable shader caching through `ShaderFactory`. | -| `_destroyShaders?: boolean` | `true` | Destroy cached shaders when their factory reference count reaches zero. | -| `_cachePipelines?: boolean` | `true` | Enable `PipelineFactory` wrapper caching. | -| `_sharePipelines?: boolean` | `true` | When pipeline caching is enabled, allow compatible WebGL render-pipeline wrappers to share a linked `WebGLProgram`. | -| `_destroyPipelines?: boolean` | `true` | Destroy cached pipelines when their factory reference count reaches zero. | +| Property | Default | Description | +| ----------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_cacheShaders?: boolean` | `true` | Enable shader caching through `ShaderFactory`. | +| `_destroyShaders?: boolean` | `false` | Destroy cached shaders when their factory reference count reaches zero. Keep this `false` by default so repeated create/destroy cycles can still hit the shader cache; enable it when an application creates very large numbers of distinct shaders and needs eviction. | +| `_cachePipelines?: boolean` | `true` | Enable `PipelineFactory` wrapper caching. | +| `_sharePipelines?: boolean` | `true` | When pipeline caching is enabled, allow compatible WebGL render-pipeline wrappers to share a linked `WebGLProgram`. | +| `_destroyPipelines?: boolean` | `false` | Destroy cached pipelines when their factory reference count reaches zero. Keep this `false` by default so repeated create/destroy cycles can still hit the pipeline cache; enable it when an application creates very large numbers of distinct pipelines and needs eviction. | #### WebGLContextAttributes diff --git a/docs/api-reference/core/resources/render-pipeline.md b/docs/api-reference/core/resources/render-pipeline.md index d38dee2b04..cbac35592b 100644 --- a/docs/api-reference/core/resources/render-pipeline.md +++ b/docs/api-reference/core/resources/render-pipeline.md @@ -79,28 +79,31 @@ const pipeline = device.createRenderPipeline({vs, fs, varyings: ['gl_Position']} ### RenderPipelineProps -| Property | Type | Default | Mutable? | Description | -| --------------------- | -------------------------- | ------- | -------- | ------------------------------------------------------------------------ | +| Property | Type | Default | Mutable? | Description | +| --------------------- | -------------------------- | ------- | -------- | ------------------------------------------------------------------------- | | Shader | -| `vs?` | `Shader` | `null` | No | Compiled vertex shader | -| `vertexEntryPoint?` | `string` | - | No | Vertex shader entry point (defaults to 'main'). WGSL only | -| `vsConstants?` | `Record` | | No | Constants to apply to compiled vertex shader (WGSL only) | -| `fs?` | `Shader` | `null` | No | Compiled fragment shader | -| `fragmentEntryPoint?` | `stringy` | | No | Fragment shader entry point (defaults to 'main'). WGSL only | -| `fsConstants?` | ` Record` | | No | Constants to apply to compiled fragment shader (WGSL only) | +| `vs?` | `Shader` | `null` | No | Compiled vertex shader | +| `vertexEntryPoint?` | `string` | - | No | Vertex shader entry point (defaults to 'main'). WGSL only | +| `vsConstants?` | `Record` | | No | Constants to apply to compiled vertex shader (WGSL only) | +| `fs?` | `Shader` | `null` | No | Compiled fragment shader | +| `fragmentEntryPoint?` | `stringy` | | No | Fragment shader entry point (defaults to 'main'). WGSL only | +| `fsConstants?` | ` Record` | | No | Constants to apply to compiled fragment shader (WGSL only) | | ShaderLayout | -| `topology?` | `PrimitiveTopology;` | | | Determines how vertices are read from the 'vertex' attributes | -| `shaderLayout?` | `ShaderLayout` | `null` | | Describes the attributes and bindings exposed by the pipeline shader(s). | -| `bufferLayout?` | `BufferLayout` | | | | +| `topology?` | `PrimitiveTopology;` | | | Determines how vertices are read from the 'vertex' attributes | +| `shaderLayout?` | `ShaderLayout` | `null` | | Describes the attributes and bindings exposed by the pipeline shader(s). | +| `bufferLayout?` | `BufferLayout` | | | | | GPU Parameters | -| `parameters?` | `RenderPipelineParameters` | | | Parameters that are controlled by pipeline | +| `parameters?` | `RenderPipelineParameters` | | | Parameters that are controlled by pipeline | +| Backend-dependent | +| `varyings?` | `string[]` | | | Transform feedback varyings captured when linking a WebGL render pipeline | +| `bufferMode?` | `number` | | | Transform feedback buffer mode used when linking a WebGL render pipeline | | Dynamic settings | -| `vertexCount?` | `number` | | | Number of "rows" in 'vertex' buffers | -| `instanceCount?` | `number` | | | Number of "rows" in 'instance' buffers | -| `indices?` | `Buffer` | `null` | | Optional index buffer | -| `attributes?` | `Record` | | | Buffers for attributes | -| `bindings?` | `Record` | | | Buffers, Textures, Samplers for the shader bindings | -| `uniforms?` | `Record` | | | uniforms (WebGL only) | +| `vertexCount?` | `number` | | | Number of "rows" in 'vertex' buffers | +| `instanceCount?` | `number` | | | Number of "rows" in 'instance' buffers | +| `indices?` | `Buffer` | `null` | | Optional index buffer | +| `attributes?` | `Record` | | | Buffers for attributes | +| `bindings?` | `Record` | | | Buffers, Textures, Samplers for the shader bindings | +| `uniforms?` | `Record` | | | uniforms (WebGL only) | - A default mapping of one buffer per attribute is always created. - @note interleaving attributes into the same buffer does not increase the number of attributes @@ -154,6 +157,7 @@ const pipeline = device.createRenderPipeline({ - `vs` (`VertexShader`|`String`) - A vertex shader object, or source as a string. - `fs` (`FragmentShader`|`String`) - A fragment shader object, or source as a string. - `varyings` WebGL (`String[]`) - a list of names of varyings. +- `bufferMode` WebGL (`number`) - transform feedback buffer mode, defaults to `GL.SEPARATE_ATTRIBS`. WebGL References [WebGLProgram](https://developer.mozilla.org/en-US/docs/Web/API/WebGLProgram), [gl.createProgram](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/createProgram) @@ -258,5 +262,6 @@ Additional WebGL behavior: - Compatible WebGL `RenderPipeline` instances may share the same linked `WebGLProgram` internally. - Shared program reuse is an optimization only. Each `RenderPipeline` still keeps its own default `topology` and `parameters`, which are used when `draw()` does not receive explicit overrides. - In practice this means pipeline wrapper identity and underlying `WebGLProgram` identity are not always the same on WebGL. +- WebGL-specific linking props such as `varyings` and `bufferMode` are part of `RenderPipelineProps`, but they are backend-dependent and only affect WebGL transform feedback pipelines. [gl.drawElementsInstancedANGLE](https://developer.mozilla.org/en-US/docs/Web/API/ANGLE_instanced_arrays/drawElementsInstancedANGLE), [gl.drawArraysInstancedANGLE](https://developer.mozilla.org/en-US/docs/Web/API/ANGLE_instanced_arrays/drawArraysInstancedANGLE) diff --git a/docs/api-reference/engine/pipeline-factory.md b/docs/api-reference/engine/pipeline-factory.md index 688b9f2fbe..9b5f7aeb69 100644 --- a/docs/api-reference/engine/pipeline-factory.md +++ b/docs/api-reference/engine/pipeline-factory.md @@ -171,8 +171,15 @@ Returns an object containing all the uniforms defined for the program. Returns ` - On WebGL, `PipelineFactory` may return different cached `RenderPipeline` wrappers that share one linked `WebGLProgram`. - Wrapper caching still respects pipeline-level defaults such as `topology`, `parameters`, and layout-related props. +- WebGL link-time props such as `varyings` and `bufferMode` are also respected when determining whether shared programs can be reused. - This lets WebGL reduce shader-link overhead without changing the per-pipeline behavior seen by direct `RenderPipeline.draw()` callers. - Device props can tune this behavior: - `_cachePipelines` enables wrapper caching. - `_sharePipelines` enables shared WebGL program reuse across compatible wrappers. - - `_destroyPipelines` controls whether unused cached pipelines are destroyed when their reference count reaches zero. + - `_destroyPipelines` controls whether unused cached pipelines are destroyed when their reference count reaches zero. The default is `false` so repeated create/destroy cycles can still benefit from the cache; turn it on only if the application creates enough distinct pipelines that cache growth becomes a problem. + +## Eviction + +By default, `PipelineFactory` keeps unused cached pipelines alive after their reference count reaches zero. This is intentional: applications often create and destroy the same pipeline shapes repeatedly, and retaining them allows later requests to hit the cache instead of recompiling or relinking pipeline state. + +If an application creates very large numbers of distinct pipelines and cache growth becomes a memory concern, set `device.props._destroyPipelines` to `true`. In that mode, `PipelineFactory.release()` will evict cached pipelines once they become unused, trading memory usage for more frequent pipeline recreation work. diff --git a/modules/core/src/adapter/device.ts b/modules/core/src/adapter/device.ts index f4c7adc5ac..0e16fdbd71 100644 --- a/modules/core/src/adapter/device.ts +++ b/modules/core/src/adapter/device.ts @@ -297,13 +297,21 @@ export type DeviceProps = { _initializeFeatures?: boolean; /** Enable shader caching (via ShaderFactory) */ _cacheShaders?: boolean; - /** Destroy cached shaders when they become unused. */ + /** + * Destroy cached shaders when they become unused. + * Defaults to `false` so repeated create/destroy cycles can still reuse cached shaders. + * Enable this if the application creates very large numbers of distinct shaders and needs cache eviction. + */ _destroyShaders?: boolean; - /** Enable shader caching (via PipelineFactory) */ + /** Enable pipeline caching (via PipelineFactory) */ _cachePipelines?: boolean; /** Enable sharing of backend render-pipeline implementations when caching is enabled. Currently used by WebGL. */ _sharePipelines?: boolean; - /** Destroy cached pipelines when they become unused. */ + /** + * Destroy cached pipelines when they become unused. + * Defaults to `false` so repeated create/destroy cycles can still reuse cached pipelines. + * Enable this if the application creates very large numbers of distinct pipelines and needs cache eviction. + */ _destroyPipelines?: boolean; /** @deprecated Internal, Do not use directly! Use `luma.attachDevice()` to attach to pre-created contexts/devices. */ @@ -385,10 +393,10 @@ export abstract class Device { _reuseDevices: false, _requestMaxLimits: true, _cacheShaders: true, - _destroyShaders: true, + _destroyShaders: false, _cachePipelines: true, _sharePipelines: true, - _destroyPipelines: true, + _destroyPipelines: false, // TODO - Change these after confirming things work as expected _initializeFeatures: true, _disabledFeatures: { diff --git a/modules/core/src/adapter/resources/render-pipeline.ts b/modules/core/src/adapter/resources/render-pipeline.ts index 7b854bb78c..b6081b31d0 100644 --- a/modules/core/src/adapter/resources/render-pipeline.ts +++ b/modules/core/src/adapter/resources/render-pipeline.ts @@ -51,6 +51,11 @@ export type RenderPipelineProps = ResourceProps & { /** Parameters that are controlled by pipeline */ parameters?: RenderPipelineParameters; + /** Transform feedback varyings captured when linking a WebGL render pipeline. WebGL only. */ + varyings?: string[]; + /** Transform feedback buffer mode used when linking a WebGL render pipeline. WebGL only. */ + bufferMode?: number; + /** Some applications intentionally supply unused attributes and bindings, and want to disable warnings */ disableWarnings?: boolean; @@ -81,8 +86,6 @@ export abstract class RenderPipeline extends Resource { linkStatus: 'pending' | 'success' | 'error' = 'pending'; /** The hash of the pipeline */ hash: string = ''; - /** Optional lower-level implementation hash for shared backend pipeline state */ - implementationHash: string = ''; /** Optional shared backend implementation */ sharedRenderPipeline: SharedRenderPipeline | null = null; @@ -163,6 +166,8 @@ export abstract class RenderPipeline extends Resource { depthStencilAttachmentFormat: undefined!, parameters: {}, + varyings: undefined!, + bufferMode: undefined!, disableWarnings: false, _sharedRenderPipeline: undefined!, bindings: undefined! diff --git a/modules/core/src/adapter/resources/shared-render-pipeline.ts b/modules/core/src/adapter/resources/shared-render-pipeline.ts index 695e1bc0c6..d34795d72f 100644 --- a/modules/core/src/adapter/resources/shared-render-pipeline.ts +++ b/modules/core/src/adapter/resources/shared-render-pipeline.ts @@ -3,14 +3,23 @@ // Copyright (c) vis.gl contributors import type {Device} from '../device'; +import type {Shader} from './shader'; import {Resource, type ResourceProps} from './resource'; +export type SharedRenderPipelineProps = ResourceProps & { + handle?: unknown; + vs: Shader; + fs: Shader; + varyings?: string[]; + bufferMode?: number; +}; + /** * Internal base class for backend-specific shared render-pipeline implementations. * Backends may use this to share expensive linked/program state across multiple * `RenderPipeline` wrappers. */ -export abstract class SharedRenderPipeline extends Resource { +export abstract class SharedRenderPipeline extends Resource { override get [Symbol.toStringTag](): string { return 'SharedRenderPipeline'; } @@ -18,7 +27,14 @@ export abstract class SharedRenderPipeline extends Resource { abstract override readonly device: Device; abstract override readonly handle: unknown; - constructor(device: Device, props: ResourceProps = {}) { - super(device, props, Resource.defaultProps); + constructor(device: Device, props: SharedRenderPipelineProps) { + super(device, props, { + ...Resource.defaultProps, + handle: undefined!, + vs: undefined!, + fs: undefined!, + varyings: undefined!, + bufferMode: undefined! + }); } } diff --git a/modules/core/src/index.ts b/modules/core/src/index.ts index 2bf8407316..7051357526 100644 --- a/modules/core/src/index.ts +++ b/modules/core/src/index.ts @@ -45,7 +45,10 @@ export {Framebuffer} from './adapter/resources/framebuffer'; export type {RenderPipelineProps} from './adapter/resources/render-pipeline'; export {RenderPipeline} from './adapter/resources/render-pipeline'; -export {SharedRenderPipeline} from './adapter/resources/shared-render-pipeline'; +export { + SharedRenderPipeline, + type SharedRenderPipelineProps +} from './adapter/resources/shared-render-pipeline'; export type {RenderPassProps} from './adapter/resources/render-pass'; export {RenderPass} from './adapter/resources/render-pass'; diff --git a/modules/engine/src/factories/pipeline-factory.ts b/modules/engine/src/factories/pipeline-factory.ts index d90d2e34b0..bfd67e99e1 100644 --- a/modules/engine/src/factories/pipeline-factory.ts +++ b/modules/engine/src/factories/pipeline-factory.ts @@ -3,19 +3,13 @@ // Copyright (c) vis.gl contributors import type {RenderPipelineProps, ComputePipelineProps, SharedRenderPipeline} from '@luma.gl/core'; -import {Device, RenderPipeline, ComputePipeline, log} from '@luma.gl/core'; +import {Device, RenderPipeline, ComputePipeline, Resource, log} from '@luma.gl/core'; import type {EngineModuleState} from '../types'; import {uid} from '../utils/uid'; export type PipelineFactoryProps = RenderPipelineProps; -type RenderPipelineCacheItem = {pipeline: RenderPipeline; useCount: number}; -type ComputePipelineCacheItem = {pipeline: ComputePipeline; useCount: number}; -type SharedRenderPipelineCacheItem = {sharedRenderPipeline: SharedRenderPipeline; useCount: number}; -type WebGLRenderPipelineCacheProps = RenderPipelineProps & { - varyings?: string[]; - bufferMode?: number; -}; +type CacheItem> = {resource: ResourceT; useCount: number}; /** * Efficiently creates / caches pipelines @@ -31,16 +25,12 @@ export class PipelineFactory { } readonly device: Device; - readonly cachingEnabled: boolean; - readonly sharingEnabled: boolean; - readonly destroyPolicy: 'unused' | 'never'; - readonly debug: boolean; private _hashCounter: number = 0; private readonly _hashes: Record = {}; - private readonly _renderPipelineCache: Record = {}; - private readonly _computePipelineCache: Record = {}; - private readonly _sharedRenderPipelineCache: Record = {}; + private readonly _renderPipelineCache: Record> = {}; + private readonly _computePipelineCache: Record> = {}; + private readonly _sharedRenderPipelineCache: Record> = {}; get [Symbol.toStringTag](): string { return 'PipelineFactory'; @@ -52,15 +42,11 @@ export class PipelineFactory { constructor(device: Device) { this.device = device; - this.cachingEnabled = device.props._cachePipelines; - this.sharingEnabled = device.props._sharePipelines; - this.destroyPolicy = device.props._destroyPipelines ? 'unused' : 'never'; - this.debug = device.props.debugFactories; } /** Return a RenderPipeline matching supplied props. Reuses an equivalent pipeline if already created. */ createRenderPipeline(props: RenderPipelineProps): RenderPipeline { - if (!this.cachingEnabled) { + if (!this.device.props._cachePipelines) { return this.device.createRenderPipeline(props); } @@ -69,31 +55,28 @@ export class PipelineFactory { const cache = this._renderPipelineCache; const hash = this._hashRenderPipeline(allProps); - let pipeline: RenderPipeline = cache[hash]?.pipeline; + let pipeline: RenderPipeline = cache[hash]?.resource; if (!pipeline) { - const implementationHash = this._shouldShareRenderPipelines() - ? this._hashSharedRenderPipeline(allProps) - : ''; - const sharedRenderPipeline = this._shouldShareRenderPipelines() - ? this._getOrCreateSharedRenderPipeline(allProps, implementationHash) - : undefined; + const sharedRenderPipeline = + this.device.type === 'webgl' && this.device.props._sharePipelines + ? this.createSharedRenderPipeline(allProps) + : undefined; pipeline = this.device.createRenderPipeline({ ...allProps, id: allProps.id ? `${allProps.id}-cached` : uid('unnamed-cached'), _sharedRenderPipeline: sharedRenderPipeline }); pipeline.hash = hash; - pipeline.implementationHash = implementationHash; - cache[hash] = {pipeline, useCount: 1}; - if (this.debug) { + cache[hash] = {resource: pipeline, useCount: 1}; + if (this.device.props.debugFactories) { log.log(3, `${this}: ${pipeline} created, count=${cache[hash].useCount}`)(); } } else { cache[hash].useCount++; - if (this.debug) { + if (this.device.props.debugFactories) { log.log( 3, - `${this}: ${cache[hash].pipeline} reused, count=${cache[hash].useCount}, (id=${props.id})` + `${this}: ${cache[hash].resource} reused, count=${cache[hash].useCount}, (id=${props.id})` )(); } } @@ -103,7 +86,7 @@ export class PipelineFactory { /** Return a ComputePipeline matching supplied props. Reuses an equivalent pipeline if already created. */ createComputePipeline(props: ComputePipelineProps): ComputePipeline { - if (!this.cachingEnabled) { + if (!this.device.props._cachePipelines) { return this.device.createComputePipeline(props); } @@ -112,23 +95,23 @@ export class PipelineFactory { const cache = this._computePipelineCache; const hash = this._hashComputePipeline(allProps); - let pipeline: ComputePipeline = cache[hash]?.pipeline; + let pipeline: ComputePipeline = cache[hash]?.resource; if (!pipeline) { pipeline = this.device.createComputePipeline({ ...allProps, id: allProps.id ? `${allProps.id}-cached` : undefined }); pipeline.hash = hash; - cache[hash] = {pipeline, useCount: 1}; - if (this.debug) { + cache[hash] = {resource: pipeline, useCount: 1}; + if (this.device.props.debugFactories) { log.log(3, `${this}: ${pipeline} created, count=${cache[hash].useCount}`)(); } } else { cache[hash].useCount++; - if (this.debug) { + if (this.device.props.debugFactories) { log.log( 3, - `${this}: ${cache[hash].pipeline} reused, count=${cache[hash].useCount}, (id=${props.id})` + `${this}: ${cache[hash].resource} reused, count=${cache[hash].useCount}, (id=${props.id})` )(); } } @@ -137,7 +120,7 @@ export class PipelineFactory { } release(pipeline: RenderPipeline | ComputePipeline): void { - if (!this.cachingEnabled) { + if (!this.device.props._cachePipelines) { pipeline.destroy(); return; } @@ -148,52 +131,72 @@ export class PipelineFactory { cache[hash].useCount--; if (cache[hash].useCount === 0) { this._destroyPipeline(pipeline); - if (this.debug) { + if (this.device.props.debugFactories) { log.log(3, `${this}: ${pipeline} released and destroyed`)(); } } else if (cache[hash].useCount < 0) { log.error(`${this}: ${pipeline} released, useCount < 0, resetting`)(); cache[hash].useCount = 0; - } else if (this.debug) { + } else if (this.device.props.debugFactories) { log.log(3, `${this}: ${pipeline} released, count=${cache[hash].useCount}`)(); } } + createSharedRenderPipeline(props: RenderPipelineProps): SharedRenderPipeline { + const sharedPipelineHash = this._hashSharedRenderPipeline(props); + let sharedCacheItem = this._sharedRenderPipelineCache[sharedPipelineHash]; + if (!sharedCacheItem) { + const sharedRenderPipeline = this.device._createSharedRenderPipelineWebGL(props); + sharedCacheItem = {resource: sharedRenderPipeline, useCount: 0}; + this._sharedRenderPipelineCache[sharedPipelineHash] = sharedCacheItem; + } + sharedCacheItem.useCount++; + return sharedCacheItem.resource; + } + + releaseSharedRenderPipeline(pipeline: RenderPipeline): void { + if (!pipeline.sharedRenderPipeline) { + return; + } + + const sharedPipelineHash = this._hashSharedRenderPipeline(pipeline.sharedRenderPipeline.props); + const sharedCacheItem = this._sharedRenderPipelineCache[sharedPipelineHash]; + if (!sharedCacheItem) { + return; + } + + sharedCacheItem.useCount--; + if (sharedCacheItem.useCount === 0) { + sharedCacheItem.resource.destroy(); + delete this._sharedRenderPipelineCache[sharedPipelineHash]; + } + } + // PRIVATE - /** Destroy a cached pipeline, removing it from the cache (depending on destroy policy) */ + /** Destroy a cached pipeline, removing it from the cache if configured to do so. */ private _destroyPipeline(pipeline: RenderPipeline | ComputePipeline): boolean { const cache = this._getCache(pipeline); - switch (this.destroyPolicy) { - case 'never': - return false; - case 'unused': - delete cache[pipeline.hash]; - pipeline.destroy(); - if ( - pipeline instanceof RenderPipeline && - pipeline.implementationHash && - this._sharedRenderPipelineCache[pipeline.implementationHash] - ) { - const sharedCacheItem = this._sharedRenderPipelineCache[pipeline.implementationHash]; - sharedCacheItem.useCount--; - if (sharedCacheItem.useCount === 0) { - sharedCacheItem.sharedRenderPipeline.destroy(); - delete this._sharedRenderPipelineCache[pipeline.implementationHash]; - } - } - return true; + if (!this.device.props._destroyPipelines) { + return false; } + + delete cache[pipeline.hash]; + pipeline.destroy(); + if (pipeline instanceof RenderPipeline) { + this.releaseSharedRenderPipeline(pipeline); + } + return true; } /** Get the appropriate cache for the type of pipeline */ private _getCache( pipeline: RenderPipeline | ComputePipeline - ): Record | Record { + ): Record> | Record> { let cache: - | Record - | Record + | Record> + | Record> | undefined; if (pipeline instanceof ComputePipeline) { cache = this._computePipelineCache; @@ -249,20 +252,6 @@ export class PipelineFactory { return `webgl/S/${vsHash}/${fsHash}V${varyingHash}`; } - private _getOrCreateSharedRenderPipeline( - props: RenderPipelineProps, - implementationHash: string - ): SharedRenderPipeline { - let sharedCacheItem = this._sharedRenderPipelineCache[implementationHash]; - if (!sharedCacheItem) { - const sharedRenderPipeline = this.device._createSharedRenderPipelineWebGL(props); - sharedCacheItem = {sharedRenderPipeline, useCount: 0}; - this._sharedRenderPipelineCache[implementationHash] = sharedCacheItem; - } - sharedCacheItem.useCount++; - return sharedCacheItem.sharedRenderPipeline; - } - private _getHash(key: string): number { if (this._hashes[key] === undefined) { this._hashes[key] = this._hashCounter++; @@ -270,12 +259,8 @@ export class PipelineFactory { return this._hashes[key]; } - private _shouldShareRenderPipelines(): boolean { - return this.device.type === 'webgl' && this.sharingEnabled; - } - private _getWebGLVaryingHash(props: RenderPipelineProps): number { - const {varyings = [], bufferMode = null} = props as WebGLRenderPipelineCacheProps; + const {varyings = [], bufferMode = null} = props; return this._getHash(JSON.stringify({varyings, bufferMode})); } } diff --git a/modules/engine/src/factories/shader-factory.ts b/modules/engine/src/factories/shader-factory.ts index 3877539d1e..501729a8e6 100644 --- a/modules/engine/src/factories/shader-factory.ts +++ b/modules/engine/src/factories/shader-factory.ts @@ -5,6 +5,8 @@ import {Device, Shader, ShaderProps, log} from '@luma.gl/core'; import type {EngineModuleState} from '../types'; +type CacheItem = {resource: Shader; useCount: number}; + /** Manages a cached pool of Shaders for reuse. */ export class ShaderFactory { static readonly defaultProps: Required = {...Shader.defaultProps}; @@ -17,11 +19,8 @@ export class ShaderFactory { } public readonly device: Device; - readonly cachingEnabled: boolean; - readonly destroyPolicy: 'unused' | 'never'; - readonly debug: boolean; - private readonly _cache: Record = {}; + private readonly _cache: Record = {}; get [Symbol.toStringTag](): string { return 'ShaderFactory'; @@ -34,14 +33,11 @@ export class ShaderFactory { /** @internal */ constructor(device: Device) { this.device = device; - this.cachingEnabled = device.props._cacheShaders; - this.destroyPolicy = device.props._destroyShaders ? 'unused' : 'never'; - this.debug = true; // device.props.debugFactories; } /** Requests a {@link Shader} from the cache, creating a new Shader only if necessary. */ createShader(props: ShaderProps): Shader { - if (!this.cachingEnabled) { + if (!this.device.props._cacheShaders) { return this.device.createShader(props); } @@ -49,30 +45,30 @@ export class ShaderFactory { let cacheEntry = this._cache[key]; if (!cacheEntry) { - const shader = this.device.createShader({ + const resource = this.device.createShader({ ...props, id: props.id ? `${props.id}-cached` : undefined }); - this._cache[key] = cacheEntry = {shader, useCount: 1}; - if (this.debug) { - log.log(3, `${this}: Created new shader ${shader.id}`)(); + this._cache[key] = cacheEntry = {resource, useCount: 1}; + if (this.device.props.debugFactories) { + log.log(3, `${this}: Created new shader ${resource.id}`)(); } } else { cacheEntry.useCount++; - if (this.debug) { + if (this.device.props.debugFactories) { log.log( 3, - `${this}: Reusing shader ${cacheEntry.shader.id} count=${cacheEntry.useCount}` + `${this}: Reusing shader ${cacheEntry.resource.id} count=${cacheEntry.useCount}` )(); } } - return cacheEntry.shader; + return cacheEntry.resource; } /** Releases a previously-requested {@link Shader}, destroying it if no users remain. */ release(shader: Shader): void { - if (!this.cachingEnabled) { + if (!this.device.props._cacheShaders) { shader.destroy(); return; } @@ -82,16 +78,16 @@ export class ShaderFactory { if (cacheEntry) { cacheEntry.useCount--; if (cacheEntry.useCount === 0) { - if (this.destroyPolicy === 'unused') { + if (this.device.props._destroyShaders) { delete this._cache[key]; - cacheEntry.shader.destroy(); - if (this.debug) { + cacheEntry.resource.destroy(); + if (this.device.props.debugFactories) { log.log(3, `${this}: Releasing shader ${shader.id}, destroyed`)(); } } } else if (cacheEntry.useCount < 0) { throw new Error(`ShaderFactory: Shader ${shader.id} released too many times`); - } else if (this.debug) { + } else if (this.device.props.debugFactories) { log.log(3, `${this}: Releasing shader ${shader.id} count=${cacheEntry.useCount}`)(); } } diff --git a/modules/engine/test/lib/pipeline-factory.spec.ts b/modules/engine/test/lib/pipeline-factory.spec.ts index d879560e10..7f92231114 100644 --- a/modules/engine/test/lib/pipeline-factory.spec.ts +++ b/modules/engine/test/lib/pipeline-factory.spec.ts @@ -95,7 +95,6 @@ test('PipelineFactory#release', async t => { } const pipelineFactory = new PipelineFactory(webglDevice); - const initialSharedRenderPipelineCount = getSharedRenderPipelineCount(webglDevice); const vs = webglDevice.createShader({stage: 'vertex', source: vsSource}); const fs = webglDevice.createShader({stage: 'fragment', source: fsSource}); diff --git a/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts b/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts index 7843e2f785..f1d67612cf 100644 --- a/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts +++ b/modules/webgl/src/adapter/resources/webgl-render-pipeline.ts @@ -83,7 +83,7 @@ export class WEBGLRenderPipeline extends RenderPipeline { if (this.destroyed) { return; } - if (this.sharedRenderPipeline && !this.implementationHash) { + if (this.sharedRenderPipeline && !this.props._sharedRenderPipeline) { this.sharedRenderPipeline.destroy(); } this.destroyResource(); diff --git a/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts b/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts index 8820f7e6da..6495405195 100644 --- a/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts +++ b/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts @@ -3,7 +3,12 @@ // Copyright (c) vis.gl contributors import {GL} from '@luma.gl/constants'; -import {SharedRenderPipeline, log, type ResourceProps, type ShaderLayout} from '@luma.gl/core'; +import { + SharedRenderPipeline, + log, + type ShaderLayout, + type SharedRenderPipelineProps +} from '@luma.gl/core'; import {getShaderLayoutFromGLSL} from '../helpers/get-shader-layout-from-glsl'; import {isGLSamplerType} from '../converters/webgl-shadertypes'; @@ -22,12 +27,10 @@ export class WEBGLSharedRenderPipeline extends SharedRenderPipeline { constructor( device: WebGLDevice, - props: ResourceProps & { + props: SharedRenderPipelineProps & { handle?: WebGLProgram; vs: WEBGLShader; fs: WEBGLShader; - varyings?: string[]; - bufferMode?: number; } ) { super(device, props); From 0a395fa703d40e630b06ad175ac080c8bddae314 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 17 Mar 2026 10:11:00 -0400 Subject: [PATCH 11/12] website shows sharing --- examples/api/multi-canvas/app.tsx | 28 ++++--- examples/api/texture-tester/app.tsx | 20 ++--- examples/tutorials/hello-gltf/app.ts | 56 ++++++++++--- .../react-luma/components/luma-example.tsx | 80 +++++++++---------- website/src/react-luma/store/device-store.tsx | 8 +- 5 files changed, 117 insertions(+), 75 deletions(-) diff --git a/examples/api/multi-canvas/app.tsx b/examples/api/multi-canvas/app.tsx index e878f38e31..3ab95a1702 100644 --- a/examples/api/multi-canvas/app.tsx +++ b/examples/api/multi-canvas/app.tsx @@ -15,6 +15,7 @@ export type DeviceType = 'webgl' | 'webgpu'; type AppProps = { deviceType?: DeviceType; + device?: Device | null; presentationDevice?: Device | null; }; @@ -64,8 +65,11 @@ export default class App extends React.PureComponent { override async componentDidUpdate(previousProps: AppProps): Promise { if ( + previousProps.device !== this.props.device || previousProps.presentationDevice !== this.props.presentationDevice || - (!this.props.presentationDevice && previousProps.deviceType !== this.props.deviceType) + (!this.props.device && + !this.props.presentationDevice && + previousProps.deviceType !== this.props.deviceType) ) { await this.initialize(); } @@ -89,20 +93,21 @@ export default class App extends React.PureComponent { throw new Error('Multi-context canvases were not mounted.'); } - const presentationDevice = this.props.presentationDevice; - const device = presentationDevice - ? presentationDevice - : await this.createOwnedDevice(deviceType); + const externalDevice = this.props.device || this.props.presentationDevice; + const device = externalDevice ? externalDevice : await this.createOwnedDevice(deviceType); const renderer = new MultiCanvasRenderer(device, canvases as HTMLCanvasElement[]); if ( !this.isComponentMounted || this.initializationGeneration !== initializationGeneration || this.props.deviceType !== deviceType || - this.props.presentationDevice !== presentationDevice + (externalDevice !== null && + externalDevice !== undefined && + this.props.device !== externalDevice && + this.props.presentationDevice !== externalDevice) ) { renderer.destroy(); - if (!presentationDevice) { + if (!externalDevice) { device.destroy(); } return; @@ -110,14 +115,17 @@ export default class App extends React.PureComponent { this.device = device; this.renderer = renderer; - this.ownsDevice = !presentationDevice; + this.ownsDevice = !externalDevice; this.renderer.start(); if ( !this.isComponentMounted || this.initializationGeneration !== initializationGeneration || this.props.deviceType !== deviceType || - this.props.presentationDevice !== presentationDevice + (externalDevice !== null && + externalDevice !== undefined && + this.props.device !== externalDevice && + this.props.presentationDevice !== externalDevice) ) { this.destroyResources(); return; @@ -199,7 +207,7 @@ export default class App extends React.PureComponent { export function renderToDOM( container: HTMLElement, - props: {deviceType?: DeviceType; presentationDevice?: Device | null} = {} + props: {deviceType?: DeviceType; device?: Device | null; presentationDevice?: Device | null} = {} ): () => void { const root: Root = createRoot(container); root.render(); diff --git a/examples/api/texture-tester/app.tsx b/examples/api/texture-tester/app.tsx index b44aa60eb8..93a22b11df 100644 --- a/examples/api/texture-tester/app.tsx +++ b/examples/api/texture-tester/app.tsx @@ -17,6 +17,7 @@ export type DeviceType = 'webgl' | 'webgpu'; type AppProps = { deviceType?: DeviceType; + device?: Device | null; presentationDevice?: Device | null; }; @@ -52,8 +53,11 @@ export default class App extends React.PureComponent { async componentDidUpdate(previousProps: AppProps) { if ( + previousProps.device !== this.props.device || previousProps.presentationDevice !== this.props.presentationDevice || - (!this.props.presentationDevice && previousProps.deviceType !== this.props.deviceType) + (!this.props.device && + !this.props.presentationDevice && + previousProps.deviceType !== this.props.deviceType) ) { await this.initializeDevice(); } @@ -72,26 +76,24 @@ export default class App extends React.PureComponent { this.setState({device: null, model: null, initializationError: null}); try { - const presentationDevice = this.props.presentationDevice; - const device = presentationDevice - ? presentationDevice - : await this.createOwnedDevice(deviceType); + const externalDevice = this.props.device || this.props.presentationDevice; + const device = externalDevice ? externalDevice : await this.createOwnedDevice(deviceType); const model = createModel(device); if ( !this.isComponentMounted || this.initializationGeneration !== initializationGeneration || this.props.deviceType !== deviceType || - this.props.presentationDevice !== presentationDevice + (this.props.device !== externalDevice && this.props.presentationDevice !== externalDevice) ) { model.destroy(); - if (!presentationDevice) { + if (!externalDevice) { device.destroy(); } return; } - this.ownsDevice = !presentationDevice; + this.ownsDevice = !externalDevice; this.setState({device, model, initializationError: null}); } catch (error) { if (this.isComponentMounted && this.initializationGeneration === initializationGeneration) { @@ -148,7 +150,7 @@ export default class App extends React.PureComponent { export function renderToDOM( container: HTMLElement, - props: {deviceType?: DeviceType; presentationDevice?: Device | null} = {} + props: {deviceType?: DeviceType; device?: Device | null; presentationDevice?: Device | null} = {} ): () => void { const root: Root = createRoot(container); root.render(); diff --git a/examples/tutorials/hello-gltf/app.ts b/examples/tutorials/hello-gltf/app.ts index cd9e21acc1..d630969440 100644 --- a/examples/tutorials/hello-gltf/app.ts +++ b/examples/tutorials/hello-gltf/app.ts @@ -73,6 +73,9 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate { cameraAnimation: true, gltfAnimation: false }; + isFinalized: boolean = false; + gltfLoadGeneration: number = 0; + cleanupCallbacks: Array<() => void> = []; constructor({device}: AnimationProps) { super(); @@ -83,11 +86,14 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate { window.localStorage[modelStorageKey] ??= this.getDefaultModelName(); this.loadGLTF(window.localStorage[modelStorageKey]); - setOptionsUI(this.options); + this.cleanupCallbacks.push(...setOptionsUI(this.options)); this.fetchModelList().then(models => { + if (this.isFinalized) { + return; + } const currentModel = window.localStorage[modelStorageKey]; - setModelMenu( + const cleanupModelMenu = setModelMenu( models.map(model => model.name), currentModel, (modelName: string) => { @@ -95,18 +101,30 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate { window.localStorage[modelStorageKey] = modelName; } ); + this.cleanupCallbacks.push(cleanupModelMenu); }); - this.device.getDefaultCanvasContext().canvas.addEventListener('mousemove', event => { + const mouseMoveHandler = (event: Event) => { const mouseEvent = event as MouseEvent; if (mouseEvent.buttons) { this.mouseCameraTime -= mouseEvent.movementX * 3.5; } - }); + }; + const canvas = this.device.getDefaultCanvasContext().canvas; + canvas.addEventListener('mousemove', mouseMoveHandler); + this.cleanupCallbacks.push(() => canvas.removeEventListener('mousemove', mouseMoveHandler)); } onFinalize() { + this.isFinalized = true; + this.gltfLoadGeneration++; + for (const cleanupCallback of this.cleanupCallbacks) { + cleanupCallback(); + } + this.cleanupCallbacks = []; destroyScenegraphs(this.scenegraphsFromGLTF); + this.scenegraphsFromGLTF = undefined; + this.modelLights = []; } getDefaultModelName(): string { @@ -172,6 +190,7 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate { } async loadGLTF(modelName: string) { + const loadGeneration = ++this.gltfLoadGeneration; const canvas = this.device.getDefaultCanvasContext().canvas as HTMLCanvasElement; try { @@ -190,6 +209,11 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate { useTangents: true }); + if (this.isFinalized || loadGeneration !== this.gltfLoadGeneration) { + destroyScenegraphs(scenegraphsFromGLTF); + return; + } + destroyScenegraphs(this.scenegraphsFromGLTF); this.scenegraphsFromGLTF = scenegraphsFromGLTF; this.modelLights = scenegraphsFromGLTF.lights; @@ -204,6 +228,9 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate { canvas.style.opacity = '1'; showError(); } catch (error) { + if (this.isFinalized || loadGeneration !== this.gltfLoadGeneration) { + return; + } canvas.style.opacity = '1'; showError(error as Error); } @@ -222,17 +249,18 @@ function setModelMenu( items: string[], currentItem: string, onMenuItemSelected: (item: string) => void -) { +): () => void { const modelSelector = document.getElementById('model-select') as HTMLSelectElement; if (!modelSelector) { - return; + return () => {}; } modelSelector.replaceChildren(); - modelSelector.addEventListener('change', event => { + const changeHandler = (event: Event) => { const name = (event.target as HTMLSelectElement).value; onMenuItemSelected(name); - }); + }; + modelSelector.addEventListener('change', changeHandler); const options = items.map(item => { const option = document.createElement('option'); @@ -243,19 +271,25 @@ function setModelMenu( modelSelector.append(...options); modelSelector.value = currentItem; + + return () => modelSelector.removeEventListener('change', changeHandler); } -function setOptionsUI(options: Record) { +function setOptionsUI(options: Record): Array<() => void> { + const cleanupCallbacks: Array<() => void> = []; for (const id of Object.keys(options)) { const checkbox = document.getElementById(id) as HTMLInputElement; if (checkbox) { checkbox.checked = options[id]; - checkbox.addEventListener('change', () => { + const changeHandler = () => { options[id] = checkbox.checked; saveOptions(options); - }); + }; + checkbox.addEventListener('change', changeHandler); + cleanupCallbacks.push(() => checkbox.removeEventListener('change', changeHandler)); } } + return cleanupCallbacks; } function loadOptions(defaultOptions: Record): Record { diff --git a/website/src/react-luma/components/luma-example.tsx b/website/src/react-luma/components/luma-example.tsx index 376eb63d30..645915c35d 100644 --- a/website/src/react-luma/components/luma-example.tsx +++ b/website/src/react-luma/components/luma-example.tsx @@ -1,13 +1,6 @@ import React, {CSSProperties, FC, useEffect, useRef, useState} from 'react'; // eslint-disable-line import {Device, luma} from '@luma.gl/core'; -import { - AnimationLoopTemplate, - AnimationLoop, - makeAnimationLoop, - setPathPrefix -} from '@luma.gl/engine'; -import {webgl2Adapter} from '@luma.gl/webgl'; -import {webgpuAdapter} from '@luma.gl/webgpu'; +import {AnimationLoopTemplate, AnimationLoop, makeAnimationLoop, setPathPrefix} from '@luma.gl/engine'; import {StatsWidget} from '@probe.gl/stats-widget'; import type {Stat, Stats} from '@probe.gl/stats'; import {DeviceTabs} from './device-tabs'; @@ -17,10 +10,11 @@ import { } from '../debug/luma-cpu-hotspot-profiler'; // import {VRDisplay} from '@luma.gl/experimental'; -import {useStore} from '../store/device-store'; +import {getCanvasContainer, useStore} from '../store/device-store'; const GITHUB_TREE = 'https://github.com/visgl/luma.gl/tree/master'; let isInfoBoxCollapsedByDefault = true; +const statsWidgetCollapsedStateByTitle: Record = {}; // WORKAROUND FOR luma.gl VRDisplay // if (!globalThis.navigator) {// eslint-disable-line @@ -77,6 +71,16 @@ function initializeGpuTimeAndMemoryStats() { return luma.stats.get('GPU Time and Memory'); } +function getStatsWidgetCollapsedState(statsWidget: StatsWidget): boolean { + return statsWidget.title ? (statsWidgetCollapsedStateByTitle[statsWidget.title] ?? true) : true; +} + +function storeStatsWidgetCollapsedState(statsWidget: StatsWidget): void { + if (statsWidget.title) { + statsWidgetCollapsedStateByTitle[statsWidget.title] = statsWidget.collapsed; + } +} + function getAdapterLabel(device: Device | null): string { switch (device?.type) { case 'webgl': @@ -356,7 +360,7 @@ export function ReactExample

(props: ReactExampleProps

) { ]; for (const statsWidget of statsWidgets) { - statsWidget.setCollapsed(true); + statsWidget.setCollapsed(getStatsWidgetCollapsedState(statsWidget)); } const updateStatsWidget = () => { @@ -371,6 +375,7 @@ export function ReactExample

(props: ReactExampleProps

) { return () => { window.clearInterval(statsIntervalId); for (const statsWidget of statsWidgets) { + storeStatsWidgetCollapsedState(statsWidget); statsWidget.remove(); } statsPanelRef.current?.replaceChildren(); @@ -403,44 +408,39 @@ export function ReactExample

(props: ReactExampleProps

) { } export const LumaExample: FC = (props: LumaExampleProps) => { - let containerName = 'ssr'; const showStats = props.showStats !== false && props.panel !== false; const showHeader = props.showHeader !== false && props.panel !== false; /** Each example maintains an animation loop */ - const [canvas, setCanvas] = useState(null); - const usedCanvases = useRef(new WeakMap()); + const canvasContainerRef = useRef(null); const currentTask = useRef | null>(null); const statsContainerRef = useRef(null); const statsPanelRef = useRef(null); - const statsWidgetCollapsedState = useRef>({}); /** Type type of the device (WebGL, WebGPU, ...) */ const deviceType = useStore(store => store.deviceType); - containerName = props.container || `luma-example-container-${deviceType}`; + const device = useStore(store => store.device); useEffect(() => { - if (!canvas || !deviceType || usedCanvases.current.get(canvas)) return; - - usedCanvases.current.set(canvas, true); + if (!canvasContainerRef.current || !deviceType || !device) { + return; + } let animationLoop: AnimationLoop | null = null; - let device: Device | null = null; let statsWidgets: StatsWidget[] = []; let statsIntervalId: number | null = null; let previousSwapChainTextureMemory = 0; + const defaultCanvasContext = device.getDefaultCanvasContext(); + const deviceCanvas = defaultCanvasContext.canvas; const asyncCreateLoop = async () => { - // canvas.style.width = '100%'; - // canvas.style.height = '100%'; - device = await luma.createDevice({ - adapters: [webgl2Adapter, webgpuAdapter], - type: deviceType, - debugGPUTime: true, - createCanvasContext: { - canvas, - container: containerName - } - }); + if (!(deviceCanvas instanceof HTMLCanvasElement)) { + throw new Error('Website examples require the shared device canvas to be an HTMLCanvasElement'); + } + + deviceCanvas.style.display = EXAMPLE_CANVAS_STYLE.display; + deviceCanvas.style.width = EXAMPLE_CANVAS_STYLE.width; + deviceCanvas.style.height = EXAMPLE_CANVAS_STYLE.height; + canvasContainerRef.current?.replaceChildren(deviceCanvas); setActiveCpuHotspotProfilerDevice(device); animationLoop = makeAnimationLoop(props.template as unknown as typeof AnimationLoopTemplate, { @@ -488,10 +488,7 @@ export const LumaExample: FC = (props: LumaExampleProps) => { }) ]; for (const statsWidget of statsWidgets) { - const collapsed = statsWidget.title - ? statsWidgetCollapsedState.current[statsWidget.title] - : undefined; - statsWidget.setCollapsed(collapsed ?? true); + statsWidget.setCollapsed(getStatsWidgetCollapsedState(statsWidget)); } const updateStatsWidget = () => { @@ -543,9 +540,7 @@ export const LumaExample: FC = (props: LumaExampleProps) => { previousSwapChainTextureMemory = 0; } for (const statsWidget of statsWidgets) { - if (statsWidget.title) { - statsWidgetCollapsedState.current[statsWidget.title] = statsWidget.collapsed; - } + storeStatsWidgetCollapsedState(statsWidget); statsWidget.remove(); } statsPanelRef.current?.replaceChildren(); @@ -555,16 +550,15 @@ export const LumaExample: FC = (props: LumaExampleProps) => { animationLoop = null; } - if (device) { - clearActiveCpuHotspotProfilerDevice(device); - device.destroy(); - } + clearActiveCpuHotspotProfilerDevice(device); + canvasContainerRef.current?.replaceChildren(); + getCanvasContainer().appendChild(deviceCanvas); }) .catch(error => { console.error(`unmounting ${deviceType} failed`, error); }); }; - }, [deviceType, canvas, showStats]); + }, [deviceType, device, showStats, props.template, props.directory, props.id]); // @ts-expect-error Intentionally accessing undeclared field info const info = props.template?.info; @@ -606,7 +600,7 @@ export const LumaExample: FC = (props: LumaExampleProps) => { }} /> ) : null} - +

{props.children} diff --git a/website/src/react-luma/store/device-store.tsx b/website/src/react-luma/store/device-store.tsx index 5ccf85758f..0fe88c8425 100644 --- a/website/src/react-luma/store/device-store.tsx +++ b/website/src/react-luma/store/device-store.tsx @@ -25,7 +25,7 @@ let cachedPresentationDevice: Record> = {}; let deviceRequestGeneration = 0; let cachedContainer: HTMLDivElement | undefined; -function getCanvasContainer() { +export function getCanvasContainer() { if (!cachedContainer) { cachedContainer = document.createElement('div'); cachedContainer.style.display = 'none'; @@ -43,13 +43,17 @@ export async function createDevice(type: 'webgl' | 'webgpu'): Promise { debugGPUTime: true, createCanvasContext: { container: getCanvasContainer(), - alphaMode: 'opaque', + alphaMode: 'opaque' } }); return await cachedDevice[type]; } export async function createPresentationDevice(type: 'webgl' | 'webgpu'): Promise { + if (type === 'webgpu') { + return await createDevice(type); + } + if (typeof OffscreenCanvas === 'undefined') { throw new Error('Presentation devices require OffscreenCanvas support'); } From fafffc46883acbf30f9884226a190949c169d63b Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 17 Mar 2026 10:59:31 -0400 Subject: [PATCH 12/12] fix-tests --- modules/core/src/adapter/canvas-observer.ts | 130 ++++++++++ modules/core/src/adapter/canvas-surface.ts | 96 ++------ .../core/test/adapter/canvas-context.spec.ts | 141 +++++++++-- .../core/test/adapter/canvas-observer.spec.ts | 229 ++++++++++++++++++ modules/engine/src/model/model.ts | 6 +- modules/engine/test/lib/model.spec.ts | 8 +- .../engine/test/lib/pipeline-factory.spec.ts | 24 +- .../engine/test/lib/shader-factory.spec.ts | 8 +- 8 files changed, 523 insertions(+), 119 deletions(-) create mode 100644 modules/core/src/adapter/canvas-observer.ts create mode 100644 modules/core/test/adapter/canvas-observer.spec.ts diff --git a/modules/core/src/adapter/canvas-observer.ts b/modules/core/src/adapter/canvas-observer.ts new file mode 100644 index 0000000000..a357685a89 --- /dev/null +++ b/modules/core/src/adapter/canvas-observer.ts @@ -0,0 +1,130 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +type CanvasObserverProps = { + canvas?: HTMLCanvasElement; + trackPosition: boolean; + onResize: (entries: ResizeObserverEntry[]) => void; + onIntersection: (entries: IntersectionObserverEntry[]) => void; + onDevicePixelRatioChange: () => void; + onPositionChange: () => void; +}; + +/** + * Internal DOM observer orchestration for HTML canvas surfaces. + * + * CanvasSurface owns the tracked state and device callback dispatch. This helper only manages + * browser observers, timers, and polling loops, then reports events through callbacks. + */ +export class CanvasObserver { + readonly props: CanvasObserverProps; + + private _resizeObserver: ResizeObserver | undefined; + private _intersectionObserver: IntersectionObserver | undefined; + private _observeDevicePixelRatioTimeout: ReturnType | null = null; + private _observeDevicePixelRatioMediaQuery: MediaQueryList | null = null; + private readonly _handleDevicePixelRatioChange = () => this._refreshDevicePixelRatio(); + private _trackPositionInterval: ReturnType | null = null; + private _started = false; + + get started(): boolean { + return this._started; + } + + constructor(props: CanvasObserverProps) { + this.props = props; + } + + start(): void { + if (this._started || !this.props.canvas) { + return; + } + + this._started = true; + this._intersectionObserver ||= new IntersectionObserver(entries => + this.props.onIntersection(entries) + ); + this._resizeObserver ||= new ResizeObserver(entries => this.props.onResize(entries)); + + this._intersectionObserver.observe(this.props.canvas); + try { + this._resizeObserver.observe(this.props.canvas, {box: 'device-pixel-content-box'}); + } catch { + this._resizeObserver.observe(this.props.canvas, {box: 'content-box'}); + } + + this._observeDevicePixelRatioTimeout = setTimeout(() => this._refreshDevicePixelRatio(), 0); + + if (this.props.trackPosition) { + this._trackPosition(); + } + } + + stop(): void { + if (!this._started) { + return; + } + + this._started = false; + + if (this._observeDevicePixelRatioTimeout) { + clearTimeout(this._observeDevicePixelRatioTimeout); + this._observeDevicePixelRatioTimeout = null; + } + + if (this._observeDevicePixelRatioMediaQuery) { + this._observeDevicePixelRatioMediaQuery.removeEventListener( + 'change', + this._handleDevicePixelRatioChange + ); + this._observeDevicePixelRatioMediaQuery = null; + } + + if (this._trackPositionInterval) { + clearInterval(this._trackPositionInterval); + this._trackPositionInterval = null; + } + + this._resizeObserver?.disconnect(); + this._intersectionObserver?.disconnect(); + } + + private _refreshDevicePixelRatio(): void { + if (!this._started) { + return; + } + + this.props.onDevicePixelRatioChange(); + + this._observeDevicePixelRatioMediaQuery?.removeEventListener( + 'change', + this._handleDevicePixelRatioChange + ); + this._observeDevicePixelRatioMediaQuery = matchMedia( + `(resolution: ${window.devicePixelRatio}dppx)` + ); + this._observeDevicePixelRatioMediaQuery.addEventListener( + 'change', + this._handleDevicePixelRatioChange, + {once: true} + ); + } + + private _trackPosition(intervalMs: number = 100): void { + if (this._trackPositionInterval) { + return; + } + + this._trackPositionInterval = setInterval(() => { + if (!this._started) { + if (this._trackPositionInterval) { + clearInterval(this._trackPositionInterval); + this._trackPositionInterval = null; + } + } else { + this.props.onPositionChange(); + } + }, intervalMs); + } +} diff --git a/modules/core/src/adapter/canvas-surface.ts b/modules/core/src/adapter/canvas-surface.ts index dbacefdc1f..0013266943 100644 --- a/modules/core/src/adapter/canvas-surface.ts +++ b/modules/core/src/adapter/canvas-surface.ts @@ -5,6 +5,7 @@ import {isBrowser} from '@probe.gl/env'; import type {Device} from './device'; import type {CanvasContext} from './canvas-context'; +import {CanvasObserver} from './canvas-observer'; import type {PresentationContext} from './presentation-context'; import type {Framebuffer} from './resources/framebuffer'; import type {TextureFormatDepthStencil} from '../shadertypes/textures/texture-formats'; @@ -111,15 +112,7 @@ export abstract class CanvasSurface { /** Resolves when the canvas is initialized, i.e. when the ResizeObserver has updated the pixel size */ protected _initializedResolvers = withResolvers(); - /** ResizeObserver to track canvas size changes */ - protected _resizeObserver: ResizeObserver | undefined; - /** IntersectionObserver to track canvas visibility changes */ - protected _intersectionObserver: IntersectionObserver | undefined; - private _observeDevicePixelRatioTimeout: ReturnType | null = null; - private _observeDevicePixelRatioMediaQuery: MediaQueryList | null = null; - private readonly _handleDevicePixelRatioChange = () => this._observeDevicePixelRatio(); - private _trackPositionInterval: ReturnType | null = null; - private _observersStarted = false; + protected _canvasObserver: CanvasObserver; /** Position of the canvas in the document, updated by a timer */ protected _position: [number, number] = [0, 0]; /** Whether this canvas context has been destroyed */ @@ -170,6 +163,14 @@ export abstract class CanvasSurface { this.drawingBufferHeight = this.canvas.height; this.devicePixelRatio = globalThis.devicePixelRatio || 1; this._position = [0, 0]; + this._canvasObserver = new CanvasObserver({ + canvas: this.htmlCanvas, + trackPosition: this.props.trackPosition, + onResize: entries => this._handleResize(entries), + onIntersection: entries => this._handleIntersection(entries), + onDevicePixelRatioChange: () => this._observeDevicePixelRatio(), + onPositionChange: () => this.updatePosition() + }); } destroy() { @@ -296,28 +297,10 @@ export abstract class CanvasSurface { * `ResizeObserver` and DPR callbacks running against a partially initialized device. */ _startObservers(): void { - if (this.destroyed || this._observersStarted || !CanvasSurface.isHTMLCanvas(this.canvas)) { + if (this.destroyed) { return; } - - this._observersStarted = true; - this._intersectionObserver ||= new IntersectionObserver(entries => - this._handleIntersection(entries) - ); - this._resizeObserver ||= new ResizeObserver(entries => this._handleResize(entries)); - - this._intersectionObserver.observe(this.canvas); - try { - this._resizeObserver.observe(this.canvas, {box: 'device-pixel-content-box'}); - } catch { - this._resizeObserver.observe(this.canvas, {box: 'content-box'}); - } - - this._observeDevicePixelRatioTimeout = setTimeout(() => this._observeDevicePixelRatio(), 0); - - if (this.props.trackPosition) { - this._trackPosition(); - } + this._canvasObserver.start(); } /** @@ -329,28 +312,7 @@ export abstract class CanvasSurface { * lifetime of the owning device. */ _stopObservers(): void { - this._observersStarted = false; - - if (this._observeDevicePixelRatioTimeout) { - clearTimeout(this._observeDevicePixelRatioTimeout); - this._observeDevicePixelRatioTimeout = null; - } - - if (this._observeDevicePixelRatioMediaQuery) { - this._observeDevicePixelRatioMediaQuery.removeEventListener( - 'change', - this._handleDevicePixelRatioChange - ); - this._observeDevicePixelRatioMediaQuery = null; - } - - if (this._trackPositionInterval) { - clearInterval(this._trackPositionInterval); - this._trackPositionInterval = null; - } - - this._resizeObserver?.disconnect(); - this._intersectionObserver?.disconnect(); + this._canvasObserver.stop(); } protected _handleIntersection(entries: IntersectionObserverEntry[]) { @@ -438,7 +400,7 @@ export abstract class CanvasSurface { } _observeDevicePixelRatio() { - if (this.destroyed || !this._observersStarted) { + if (this.destroyed || !this._canvasObserver.started) { return; } const oldRatio = this.devicePixelRatio; @@ -449,36 +411,6 @@ export abstract class CanvasSurface { this.device.props.onDevicePixelRatioChange?.(this as CanvasContext | PresentationContext, { oldRatio }); - - this._observeDevicePixelRatioMediaQuery?.removeEventListener( - 'change', - this._handleDevicePixelRatioChange - ); - this._observeDevicePixelRatioMediaQuery = matchMedia( - `(resolution: ${this.devicePixelRatio}dppx)` - ); - this._observeDevicePixelRatioMediaQuery.addEventListener( - 'change', - this._handleDevicePixelRatioChange, - {once: true} - ); - } - - _trackPosition(intervalMs: number = 100): void { - if (this._trackPositionInterval) { - return; - } - - this._trackPositionInterval = setInterval(() => { - if (this.destroyed || !this._observersStarted) { - if (this._trackPositionInterval) { - clearInterval(this._trackPositionInterval); - this._trackPositionInterval = null; - } - } else { - this.updatePosition(); - } - }, intervalMs); } updatePosition() { diff --git a/modules/core/test/adapter/canvas-context.spec.ts b/modules/core/test/adapter/canvas-context.spec.ts index 318549a452..6a1f9f6684 100644 --- a/modules/core/test/adapter/canvas-context.spec.ts +++ b/modules/core/test/adapter/canvas-context.spec.ts @@ -156,19 +156,15 @@ function createContextSuite( return; } - const calls = {resizeObserverDisconnect: 0, intersectionObserverDisconnect: 0}; + const calls = {stop: 0}; const canvasContext = createContext(); // @ts-expect-error read only - canvasContext._resizeObserver = { - disconnect: () => { - calls.resizeObserverDisconnect++; - } - }; - // @ts-expect-error read only - canvasContext._intersectionObserver = { - disconnect: () => { - calls.intersectionObserverDisconnect++; - } + canvasContext._canvasObserver = { + start: () => {}, + stop: () => { + calls.stop++; + }, + started: true }; t.doesNotThrow(() => { @@ -176,12 +172,7 @@ function createContextSuite( canvasContext.destroy(); }, 'destroying twice should be safe'); - t.equal(calls.resizeObserverDisconnect, 1, 'resize observer disconnected exactly once'); - t.equal( - calls.intersectionObserverDisconnect, - 1, - 'intersection observer disconnected exactly once' - ); + t.equal(calls.stop, 1, 'canvas observer stopped exactly once'); t.end(); }); @@ -306,6 +297,122 @@ test('CanvasContext#_startObservers defers DOM observation until explicitly star t.end(); }); +test('CanvasContext#_startObservers is idempotent', t => { + if (!isBrowser()) { + t.end(); + return; + } + + const globalScope = globalThis as any; + const originalResizeObserver = globalScope.ResizeObserver; + const originalIntersectionObserver = globalScope.IntersectionObserver; + const originalSetTimeout = globalScope.setTimeout; + + const calls = { + resizeObserverObserve: 0, + intersectionObserverObserve: 0, + setTimeout: 0 + }; + + globalScope.ResizeObserver = class { + constructor(_callback: ResizeObserverCallback) {} + observe() { + calls.resizeObserverObserve++; + } + disconnect() {} + }; + globalScope.IntersectionObserver = class { + constructor(_callback: IntersectionObserverCallback) {} + observe() { + calls.intersectionObserverObserve++; + } + disconnect() {} + }; + globalScope.setTimeout = (callback: () => void) => { + calls.setTimeout++; + return originalSetTimeout(callback, 0); + }; + + try { + const canvasContext = new TestCanvasContext({}, false); + canvasContext._startObservers(); + canvasContext._startObservers(); + + t.equal(calls.resizeObserverObserve, 1, 'resize observer only starts once'); + t.equal(calls.intersectionObserverObserve, 1, 'intersection observer only starts once'); + t.equal(calls.setTimeout, 1, 'deferred DPR observation is only scheduled once'); + + canvasContext.destroy(); + } finally { + globalScope.ResizeObserver = originalResizeObserver; + globalScope.IntersectionObserver = originalIntersectionObserver; + globalScope.setTimeout = originalSetTimeout; + } + + t.end(); +}); + +test('CanvasContext#trackPosition polling stops on destroy', t => { + if (!isBrowser()) { + t.end(); + return; + } + + const globalScope = globalThis as any; + const originalResizeObserver = globalScope.ResizeObserver; + const originalIntersectionObserver = globalScope.IntersectionObserver; + const originalSetInterval = globalScope.setInterval; + const originalClearInterval = globalScope.clearInterval; + + let intervalCallback: (() => void) | null = null; + let clearIntervalCalls = 0; + + globalScope.ResizeObserver = class { + constructor(_callback: ResizeObserverCallback) {} + observe() {} + disconnect() {} + }; + globalScope.IntersectionObserver = class { + constructor(_callback: IntersectionObserverCallback) {} + observe() {} + disconnect() {} + }; + globalScope.setInterval = (callback: () => void) => { + intervalCallback = callback; + return 1 as ReturnType; + }; + globalScope.clearInterval = (_id: ReturnType) => { + clearIntervalCalls++; + }; + + try { + const canvasContext = new TestCanvasContext({trackPosition: true}, false); + let updatePositionCalls = 0; + canvasContext.updatePosition = () => { + updatePositionCalls++; + }; + + canvasContext._startObservers(); + + t.ok(intervalCallback, 'position polling interval is scheduled'); + intervalCallback?.(); + t.equal(updatePositionCalls, 1, 'position polling calls updatePosition while active'); + + canvasContext.destroy(); + t.equal(clearIntervalCalls, 1, 'position polling interval is cleared on destroy'); + + intervalCallback?.(); + t.equal(updatePositionCalls, 1, 'position polling no longer updates after destroy'); + } finally { + globalScope.ResizeObserver = originalResizeObserver; + globalScope.IntersectionObserver = originalIntersectionObserver; + globalScope.setInterval = originalSetInterval; + globalScope.clearInterval = originalClearInterval; + } + + t.end(); +}); + test('PresentationContext#defined', t => { t.ok(PresentationContext, 'PresentationContext defined'); t.end(); diff --git a/modules/core/test/adapter/canvas-observer.spec.ts b/modules/core/test/adapter/canvas-observer.spec.ts new file mode 100644 index 0000000000..6d2333ec1b --- /dev/null +++ b/modules/core/test/adapter/canvas-observer.spec.ts @@ -0,0 +1,229 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import test from 'tape-promise/tape'; +import {isBrowser} from '@probe.gl/env'; +import {CanvasObserver} from '../../src/adapter/canvas-observer'; + +type ObserverGlobals = { + ResizeObserver: typeof globalThis.ResizeObserver; + IntersectionObserver: typeof globalThis.IntersectionObserver; + setTimeout: typeof globalThis.setTimeout; + clearTimeout: typeof globalThis.clearTimeout; + setInterval: typeof globalThis.setInterval; + clearInterval: typeof globalThis.clearInterval; + matchMedia: typeof globalThis.matchMedia; +}; + +function getOriginalGlobals(globalScope: typeof globalThis): ObserverGlobals { + return { + ResizeObserver: globalScope.ResizeObserver, + IntersectionObserver: globalScope.IntersectionObserver, + setTimeout: globalScope.setTimeout, + clearTimeout: globalScope.clearTimeout, + setInterval: globalScope.setInterval, + clearInterval: globalScope.clearInterval, + matchMedia: globalScope.matchMedia + }; +} + +function restoreGlobals(globalScope: typeof globalThis, originals: ObserverGlobals): void { + globalScope.ResizeObserver = originals.ResizeObserver; + globalScope.IntersectionObserver = originals.IntersectionObserver; + globalScope.setTimeout = originals.setTimeout; + globalScope.clearTimeout = originals.clearTimeout; + globalScope.setInterval = originals.setInterval; + globalScope.clearInterval = originals.clearInterval; + globalScope.matchMedia = originals.matchMedia; +} + +test('CanvasObserver#start is idempotent and stop is idempotent', t => { + if (!isBrowser()) { + t.end(); + return; + } + + const globalScope = globalThis; + const originals = getOriginalGlobals(globalScope); + const calls = { + resizeObserve: 0, + resizeDisconnect: 0, + intersectionObserve: 0, + intersectionDisconnect: 0, + setTimeout: 0, + clearTimeout: 0 + }; + + globalScope.ResizeObserver = class { + constructor(_callback: ResizeObserverCallback) {} + observe() { + calls.resizeObserve++; + } + disconnect() { + calls.resizeDisconnect++; + } + } as typeof ResizeObserver; + globalScope.IntersectionObserver = class { + constructor(_callback: IntersectionObserverCallback) {} + observe() { + calls.intersectionObserve++; + } + disconnect() { + calls.intersectionDisconnect++; + } + } as typeof IntersectionObserver; + globalScope.setTimeout = (callback: TimerHandler, delay?: number) => { + calls.setTimeout++; + return originals.setTimeout(callback, delay); + }; + globalScope.clearTimeout = (timeoutId: number | undefined) => { + calls.clearTimeout++; + return originals.clearTimeout(timeoutId); + }; + + try { + const observer = new CanvasObserver({ + canvas: document.createElement('canvas'), + trackPosition: false, + onResize: () => {}, + onIntersection: () => {}, + onDevicePixelRatioChange: () => {}, + onPositionChange: () => {} + }); + + observer.start(); + observer.start(); + observer.stop(); + observer.stop(); + + t.equal(calls.resizeObserve, 1, 'resize observer only starts once'); + t.equal(calls.intersectionObserve, 1, 'intersection observer only starts once'); + t.equal(calls.setTimeout, 1, 'deferred DPR observation is only scheduled once'); + t.equal(calls.clearTimeout, 1, 'deferred DPR observation is only cleared once'); + t.equal(calls.resizeDisconnect, 1, 'resize observer only disconnects once'); + t.equal(calls.intersectionDisconnect, 1, 'intersection observer only disconnects once'); + } finally { + restoreGlobals(globalScope, originals); + } + + t.end(); +}); + +test('CanvasObserver#trackPosition polling stops after stop', t => { + if (!isBrowser()) { + t.end(); + return; + } + + const globalScope = globalThis; + const originals = getOriginalGlobals(globalScope); + + let intervalCallback: (() => void) | null = null; + let positionChangeCalls = 0; + let clearIntervalCalls = 0; + + globalScope.ResizeObserver = class { + constructor(_callback: ResizeObserverCallback) {} + observe() {} + disconnect() {} + } as typeof ResizeObserver; + globalScope.IntersectionObserver = class { + constructor(_callback: IntersectionObserverCallback) {} + observe() {} + disconnect() {} + } as typeof IntersectionObserver; + globalScope.setTimeout = (callback: TimerHandler, delay?: number) => + originals.setTimeout(callback, delay); + globalScope.setInterval = (callback: TimerHandler) => { + intervalCallback = callback as () => void; + return 1 as ReturnType; + }; + globalScope.clearInterval = (_intervalId: number | undefined) => { + clearIntervalCalls++; + }; + + try { + const observer = new CanvasObserver({ + canvas: document.createElement('canvas'), + trackPosition: true, + onResize: () => {}, + onIntersection: () => {}, + onDevicePixelRatioChange: () => {}, + onPositionChange: () => { + positionChangeCalls++; + } + }); + + observer.start(); + t.ok(intervalCallback, 'position polling interval is scheduled'); + + intervalCallback?.(); + t.equal(positionChangeCalls, 1, 'position polling callback fires while observer is active'); + + observer.stop(); + t.equal(clearIntervalCalls, 1, 'position polling interval is cleared on stop'); + + intervalCallback?.(); + t.equal(positionChangeCalls, 1, 'position polling callback does not fire after stop'); + } finally { + restoreGlobals(globalScope, originals); + } + + t.end(); +}); + +test('CanvasObserver#start is a no-op without an HTML canvas', t => { + if (!isBrowser()) { + t.end(); + return; + } + + const globalScope = globalThis; + const originals = getOriginalGlobals(globalScope); + const calls = { + resizeObserve: 0, + intersectionObserve: 0, + setTimeout: 0 + }; + + globalScope.ResizeObserver = class { + constructor(_callback: ResizeObserverCallback) {} + observe() { + calls.resizeObserve++; + } + disconnect() {} + } as typeof ResizeObserver; + globalScope.IntersectionObserver = class { + constructor(_callback: IntersectionObserverCallback) {} + observe() { + calls.intersectionObserve++; + } + disconnect() {} + } as typeof IntersectionObserver; + globalScope.setTimeout = (callback: TimerHandler, delay?: number) => { + calls.setTimeout++; + return originals.setTimeout(callback, delay); + }; + + try { + const observer = new CanvasObserver({ + trackPosition: true, + onResize: () => {}, + onIntersection: () => {}, + onDevicePixelRatioChange: () => {}, + onPositionChange: () => {} + }); + + observer.start(); + observer.stop(); + + t.equal(calls.resizeObserve, 0, 'resize observer is never started'); + t.equal(calls.intersectionObserve, 0, 'intersection observer is never started'); + t.equal(calls.setTimeout, 0, 'deferred DPR observation is never scheduled'); + } finally { + restoreGlobals(globalScope, originals); + } + + t.end(); +}); diff --git a/modules/engine/src/model/model.ts b/modules/engine/src/model/model.ts index 8bc3673b26..ba29c7b682 100644 --- a/modules/engine/src/model/model.ts +++ b/modules/engine/src/model/model.ts @@ -361,7 +361,7 @@ export class Model { this.pipelineFactory.release(this.pipeline); // Release the shaders this.shaderFactory.release(this.pipeline.vs); - if (this.pipeline.fs) { + if (this.pipeline.fs && this.pipeline.fs !== this.pipeline.vs) { this.shaderFactory.release(this.pipeline.fs); } this._uniformStore.destroy(); @@ -829,7 +829,9 @@ export class Model { ); if (prevShaderVs) this.shaderFactory.release(prevShaderVs); - if (prevShaderFs) this.shaderFactory.release(prevShaderFs); + if (prevShaderFs && prevShaderFs !== prevShaderVs) { + this.shaderFactory.release(prevShaderFs); + } } return this.pipeline; } diff --git a/modules/engine/test/lib/model.spec.ts b/modules/engine/test/lib/model.spec.ts index f032247a6a..5876c6db49 100644 --- a/modules/engine/test/lib/model.spec.ts +++ b/modules/engine/test/lib/model.spec.ts @@ -51,9 +51,13 @@ test('Model#construct/destruct', async t => { t.ok(model, 'Model constructor does not throw errors'); t.ok(model.id, 'Model has an id'); t.ok(model.pipeline, 'Created pipeline'); + t.false(model.pipeline.destroyed, 'Pipeline starts alive'); model.destroy(); - t.true(model.pipeline.destroyed, 'Deleted pipeline'); + t.false( + model.pipeline.destroyed, + 'Pipeline wrapper remains cached by default after last release' + ); t.end(); }); @@ -82,7 +86,7 @@ test('Model#multiple delete', async t => { model1.destroy(); t.ok(model2.pipeline.destroyed === false, 'program still in use'); model2.destroy(); - t.ok(model2.pipeline.destroyed === true, 'program is released'); + t.ok(model2.pipeline.destroyed === false, 'program remains cached after last release by default'); t.end(); }); diff --git a/modules/engine/test/lib/pipeline-factory.spec.ts b/modules/engine/test/lib/pipeline-factory.spec.ts index 7f92231114..1ebee76b3a 100644 --- a/modules/engine/test/lib/pipeline-factory.spec.ts +++ b/modules/engine/test/lib/pipeline-factory.spec.ts @@ -105,7 +105,7 @@ test('PipelineFactory#release', async t => { t.ok(!pipeline1.destroyed, 'Pipeline not deleted when still referenced.'); pipelineFactory.release(pipeline2); - t.ok(pipeline2.destroyed, 'Pipeline deleted when all references released.'); + t.ok(!pipeline2.destroyed, 'Pipeline remains cached after all references are released.'); t.end(); }); @@ -166,18 +166,18 @@ test('PipelineFactory#caching with parameters', async t => { 'Shared program remains alive while another wrapper uses it' ); pipelineFactory.release(pipeline3); - t.ok( + t.equal( + getSharedRenderPipelineCount(webglDevice), + initialSharedRenderPipelineCount + 1, + 'Shared render pipeline resource remains cached after release' + ); + t.notOk( isProgramDestroyed(pipeline3.handle), - 'Shared program is deleted after the last wrapper is released' + 'Shared program remains cached after the last wrapper is released' ); - t.ok( + t.notOk( pipeline3.sharedRenderPipeline?.destroyed, - 'Shared render pipeline resource is deleted after the last wrapper is released' - ); - t.equal( - getSharedRenderPipelineCount(webglDevice), - initialSharedRenderPipelineCount, - 'Shared render pipeline resource count returns to baseline after release' + 'Shared render pipeline resource remains cached after the last wrapper is released' ); t.end(); @@ -358,8 +358,8 @@ test('PipelineFactory#sharing can be disabled independently from wrapper caching pipelineFactory.release(pipeline2); t.equal( getSharedRenderPipelineCount(webglDevice), - initialSharedRenderPipelineCount, - 'Shared render pipeline resource count returns to baseline after release' + initialSharedRenderPipelineCount + 2, + 'Shared render pipeline resources remain cached after release' ); } finally { webglDevice?.destroy(); diff --git a/modules/engine/test/lib/shader-factory.spec.ts b/modules/engine/test/lib/shader-factory.spec.ts index eb7e1927b9..d126af6078 100644 --- a/modules/engine/test/lib/shader-factory.spec.ts +++ b/modules/engine/test/lib/shader-factory.spec.ts @@ -82,15 +82,15 @@ test('ShaderFactory#release', async t => { factory.release(shader3); t.deepEqual( [shader1.destroyed, shader2.destroyed, shader3.destroyed], - [false, false, true], - 'Keeps used shaders' + [false, false, false], + 'Released shaders remain cached by default' ); factory.release(shader1); t.deepEqual( [shader1.destroyed, shader2.destroyed, shader3.destroyed], - [true, true, true], - 'Destroys unused shaders' + [false, false, false], + 'Unused shaders remain cached by default' ); t.end();