diff --git a/docs/api-reference/core/device.md b/docs/api-reference/core/device.md index a0d37a2b2b..8a1184cf1d 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` | `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 -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..cbac35592b 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,47 @@ 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 | +| 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) | + +- 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 +128,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 +144,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', @@ -154,7 +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) @@ -162,7 +165,6 @@ WebGL References [WebGLProgram](https://developer.mozilla.org/en-US/docs/Web/API 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 +210,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 +221,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 +256,12 @@ 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. +- 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 10dcfb051c..f4cba6c169 100644 --- a/docs/api-reference/engine/pipeline-factory.md +++ b/docs/api-reference/engine/pipeline-factory.md @@ -4,6 +4,10 @@ It is primarily useful when many models or computations assemble identical pipelines. Reusing those pipelines reduces redundant pipeline creation and works well together with [`ShaderFactory`](/docs/api-reference/engine/shader-factory). +:::info +Pipeline creation involves shader compilation and backend-specific linking work. That cost can become noticeable during startup and whenever applications repeatedly assemble equivalent pipelines on demand. +::: + ## Usage ```typescript @@ -22,14 +26,6 @@ pipelineFactory.release(pipeline); Device that owns the cached pipelines. -### `cachingEnabled: boolean` - -Whether pipeline reuse is enabled for the current device configuration. - -### `destroyPolicy: 'unused' | 'never'` - -Controls whether released pipelines are destroyed when the reference count reaches zero. - ## Methods ### `PipelineFactory.getDefaultPipelineFactory(device: Device): PipelineFactory` @@ -50,7 +46,24 @@ Equivalent cache-aware constructor for compute pipelines. ### `release(pipeline: RenderPipeline | ComputePipeline): void` -Releases a previously requested pipeline. When the reference count reaches zero, the pipeline is either destroyed or retained depending on `destroyPolicy`. +Releases a previously requested pipeline. When the reference count reaches zero, the pipeline is either destroyed or retained depending on `device.props._destroyPipelines`. + +## 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. +- 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. + +## 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 recreating 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. ## Remarks diff --git a/docs/whats-new.md b/docs/whats-new.md index f74e32e790..e6670ff4a1 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/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/modules/core/src/adapter/device.ts b/modules/core/src/adapter/device.ts index da23ffc268..0e16fdbd71 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,22 @@ export type DeviceProps = { _initializeFeatures?: boolean; /** Enable shader caching (via ShaderFactory) */ _cacheShaders?: boolean; - /** Enable shader caching (via PipelineFactory) */ + /** + * 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 pipeline 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. + * 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. */ _handle?: unknown; // WebGL2RenderingContext | GPUDevice | null; @@ -379,9 +392,11 @@ export abstract class Device { // Experimental _reuseDevices: false, _requestMaxLimits: true, - _cacheShaders: false, - _cachePipelines: false, - _cacheDestroyPolicy: 'unused', + _cacheShaders: true, + _destroyShaders: false, + _cachePipelines: true, + _sharePipelines: true, + _destroyPipelines: false, // TODO - Change these after confirming things work as expected _initializeFeatures: true, _disabledFeatures: { @@ -686,6 +701,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/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/render-pipeline.ts b/modules/core/src/adapter/resources/render-pipeline.ts index bf996fac3f..b6081b31d0 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'; @@ -12,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'; @@ -51,12 +51,20 @@ export type RenderPipelineProps = ResourceProps & { /** Parameters that are controlled by pipeline */ parameters?: RenderPipelineParameters; - // Dynamic bindings (TODO - pipelines should be immutable, move to RenderPass) + /** 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; + + /** 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; - /** @deprecated uniforms (WebGL only) */ - uniforms?: Record; }; /** @@ -78,6 +86,8 @@ export abstract class RenderPipeline extends Resource { linkStatus: 'pending' | 'success' | 'error' = 'pending'; /** The hash of the pipeline */ hash: string = ''; + /** Optional shared backend implementation */ + sharedRenderPipeline: SharedRenderPipeline | null = null; /** Whether shader or pipeline compilation/linking is still in progress */ get isPending(): boolean { @@ -101,14 +111,9 @@ 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; } - /** 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) */ @@ -136,6 +141,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 = { @@ -157,8 +166,10 @@ export abstract class RenderPipeline extends Resource { depthStencilAttachmentFormat: undefined!, parameters: {}, - - bindings: {}, - uniforms: {} + varyings: undefined!, + bufferMode: undefined!, + 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..912f2a64cf 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,10 +219,11 @@ 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]; + const name = this.getStatsName(); for (const stats of statsObjects) { stats.get('Resources Active').decrementCount(); stats.get(`${name}s Active`).decrementCount(); @@ -213,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); @@ -235,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; @@ -261,21 +284,22 @@ 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 = [ 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(); @@ -290,6 +314,11 @@ export abstract class Resource { } recordTransientCanvasResourceCreate(this._device, name); } + + /** Canonical resource name used for stats buckets. */ + protected getStatsName(): string { + return getCanonicalResourceName(this); + } } /** @@ -309,13 +338,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 +382,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 { @@ -401,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/core/src/adapter/resources/shared-render-pipeline.ts b/modules/core/src/adapter/resources/shared-render-pipeline.ts new file mode 100644 index 0000000000..d34795d72f --- /dev/null +++ b/modules/core/src/adapter/resources/shared-render-pipeline.ts @@ -0,0 +1,40 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// 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 { + override get [Symbol.toStringTag](): string { + return 'SharedRenderPipeline'; + } + + abstract override readonly device: Device; + abstract override readonly handle: unknown; + + 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 faf2c2af24..7051357526 100644 --- a/modules/core/src/index.ts +++ b/modules/core/src/index.ts @@ -45,6 +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, + 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 ec20c3293d..bfd67e99e1 100644 --- a/modules/engine/src/factories/pipeline-factory.ts +++ b/modules/engine/src/factories/pipeline-factory.ts @@ -2,15 +2,14 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import type {RenderPipelineProps, ComputePipelineProps} from '@luma.gl/core'; -import {Device, RenderPipeline, ComputePipeline, log} from '@luma.gl/core'; +import type {RenderPipelineProps, ComputePipelineProps, SharedRenderPipeline} 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 CacheItem> = {resource: ResourceT; useCount: number}; /** * Efficiently creates / caches pipelines @@ -26,14 +25,12 @@ export class PipelineFactory { } readonly device: Device; - readonly cachingEnabled: 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 _renderPipelineCache: Record> = {}; + private readonly _computePipelineCache: Record> = {}; + private readonly _sharedRenderPipelineCache: Record> = {}; get [Symbol.toStringTag](): string { return 'PipelineFactory'; @@ -45,14 +42,11 @@ export class PipelineFactory { constructor(device: Device) { this.device = device; - this.cachingEnabled = device.props._cachePipelines; - this.destroyPolicy = device.props._cacheDestroyPolicy; - 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); } @@ -61,23 +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 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') + id: allProps.id ? `${allProps.id}-cached` : uid('unnamed-cached'), + _sharedRenderPipeline: sharedRenderPipeline }); 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})` )(); } } @@ -87,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); } @@ -96,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})` )(); } } @@ -121,7 +120,7 @@ export class PipelineFactory { } release(pipeline: RenderPipeline | ComputePipeline): void { - if (!this.cachingEnabled) { + if (!this.device.props._cachePipelines) { pipeline.destroy(); return; } @@ -132,40 +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(); - 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; @@ -193,18 +224,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 - return `${type}/R/${vsHash}/${fsHash}V${varyingHash}BL${bufferLayoutHash}`; + // 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}T${props.topology}P${webglParameterHash}BL${bufferLayoutHash}`; case 'webgpu': default: @@ -216,10 +245,22 @@ 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 _getHash(key: string): number { if (this._hashes[key] === undefined) { this._hashes[key] = this._hashCounter++; } return this._hashes[key]; } + + private _getWebGLVaryingHash(props: RenderPipelineProps): number { + 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 098ec5ac5f..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._cacheDestroyPolicy; - 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/src/model/model.ts b/modules/engine/src/model/model.ts index 2721dd8e77..ba29c7b682 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; @@ -132,6 +134,8 @@ export class Model { indexBuffer: null, attributes: {}, constantAttributes: {}, + bindings: {}, + uniforms: {}, varyings: [], isInstanced: undefined!, @@ -357,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(); @@ -422,14 +426,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 - const syncBindings = this._getBindings(); - this.pipeline.setBindings(syncBindings, { - disableWarnings: this.props.disableWarnings - }); const {indexBuffer} = this.vertexArray; const indexCount = indexBuffer @@ -444,6 +441,11 @@ 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, // 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) @@ -827,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 7b1ffb9175..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(); }); @@ -265,20 +269,15 @@ 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.ok(model1.draw(renderPass), 'First model draw succeeded'); - model2.draw(renderPass); - t.deepEqual(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(uniforms, {x: -0.5}, 'Pipeline uniforms set'); + t.ok(model2.draw(renderPass), 'Pipeline updates still draw'); renderPass.destroy(); diff --git a/modules/engine/test/lib/pipeline-factory.spec.ts b/modules/engine/test/lib/pipeline-factory.spec.ts index cb6a23a961..1ebee76b3a 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(); @@ -61,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(); }); @@ -75,6 +119,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}); @@ -101,11 +146,267 @@ 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.equal( + getSharedRenderPipelineCount(webglDevice), + initialSharedRenderPipelineCount + 1, + 'Shared render pipeline resource remains cached after release' + ); + t.notOk( + isProgramDestroyed(pipeline3.handle), + 'Shared program remains cached after the last wrapper is released' + ); + t.notOk( + pipeline3.sharedRenderPipeline?.destroyed, + 'Shared render pipeline resource remains cached after the last wrapper is released' + ); + + 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 initialSharedRenderPipelineCount = getSharedRenderPipelineCount(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.notEqual( + pipeline1.sharedRenderPipeline, + pipeline2.sharedRenderPipeline, + 'Creates distinct shared render pipeline resources when sharing is disabled' + ); + t.equal( + getSharedRenderPipelineCount(webglDevice), + initialSharedRenderPipelineCount + 2, + 'Tracks separate shared render pipeline resources for each wrapper 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); + t.equal( + getSharedRenderPipelineCount(webglDevice), + initialSharedRenderPipelineCount + 2, + 'Shared render pipeline resources remain cached after release' + ); + } 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/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(); 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 3904f45b8d..f1d67612cf 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,10 +44,10 @@ 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 */ + /** Compatibility path for direct pipeline.setBindings() usage */ bindings: Record = {}; + /** Compatibility path for direct pipeline.uniforms usage */ + uniforms: Record = {}; /** WebGL varyings */ varyings: string[] | null = null; @@ -64,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) @@ -93,32 +80,21 @@ 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; } + if (this.sharedRenderPipeline && !this.props._sharedRenderPipeline) { + this.sharedRenderPipeline.destroy(); + } + this.destroyResource(); } /** - * Bindings include: textures, samplers and uniform buffers - * @todo needed for portable model + * 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 { - // 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`); @@ -133,7 +109,7 @@ export class WEBGLRenderPipeline extends RenderPipeline { value )(); } - continue; // eslint-disable-line no-continue + continue; } if (!value) { log.warn(`Unsetting binding "${name}" in render pipeline "${this.id}"`)(); @@ -185,7 +161,11 @@ export class WEBGLRenderPipeline extends RenderPipeline { firstInstance?: number; baseVertex?: number; transformFeedback?: WEBGLTransformFeedback; + bindings?: Record; + uniforms?: Record; }): boolean { + this._syncLinkStatus(); + const { renderPass, parameters = this.props.parameters, @@ -199,7 +179,9 @@ export class WEBGLRenderPipeline extends RenderPipeline { // firstIndex, // firstInstance, // baseVertex, - transformFeedback + transformFeedback, + bindings = this.bindings, + uniforms = this.uniforms } = options; const glDrawMode = getGLDrawMode(topology); @@ -217,7 +199,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; @@ -240,8 +222,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; @@ -279,180 +261,16 @@ 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) * 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; } @@ -469,7 +287,9 @@ export class WEBGLRenderPipeline extends RenderPipeline { } /** Apply any bindings (before each draw call) */ - _applyBindings() { + _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; @@ -482,8 +302,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}`); } @@ -561,15 +380,19 @@ 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); } } } + + 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..6495405195 --- /dev/null +++ b/modules/webgl/src/adapter/resources/webgl-shared-render-pipeline.ts @@ -0,0 +1,208 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {GL} from '@luma.gl/constants'; +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'; +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'; + + constructor( + device: WebGLDevice, + props: SharedRenderPipelineProps & { + handle?: WebGLProgram; + vs: WEBGLShader; + fs: WEBGLShader; + } + ) { + 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-pass.ts b/modules/webgpu/src/adapter/resources/webgpu-render-pass.ts index 6d6344b697..70d6a67bf4 100644 --- a/modules/webgpu/src/adapter/resources/webgpu-render-pass.ts +++ b/modules/webgpu/src/adapter/resources/webgpu-render-pass.ts @@ -21,6 +21,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; @@ -123,8 +126,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 3caf00b738..3c3e4e9012 100644 --- a/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts +++ b/modules/webgpu/src/adapter/resources/webgpu-render-pipeline.ts @@ -27,13 +27,14 @@ export class WebGPURenderPipeline extends RenderPipeline { readonly vs: WebGPUShader; readonly fs: WebGPUShader | null = null; - /** For internal use to create BindGroups */ + /** 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 'RenderPipeline'; + return 'WebGPURenderPipeline'; } constructor(device: WebGPUDevice, props: RenderPipelineProps) { @@ -58,7 +59,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 = props.bindings || EMPTY_BINDINGS; } @@ -69,8 +69,8 @@ export class WebGPURenderPipeline extends RenderPipeline { } /** - * @todo Use renderpass.setBindings() ? - * @todo Do we want to expose BindGroups in the API and remove this? + * 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; @@ -101,6 +101,8 @@ export class WebGPURenderPipeline extends RenderPipeline { firstIndex?: number; firstInstance?: number; baseVertex?: number; + bindings?: Record; + uniforms?: Record; }): boolean { const webgpuRenderPass = options.renderPass as WebGPURenderPass; const instanceCount = @@ -115,7 +117,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); } @@ -149,7 +151,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; } @@ -157,8 +159,10 @@ 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 - // TODO what if bindings change? We need to rebuild the bind group! + if (bindings) { + return getBindGroup(this.device, this._bindGroupLayout, this.shaderLayout, bindings); + } + this._bindGroup = this._bindGroup || getBindGroup(this.device, this._bindGroupLayout, this.shaderLayout, this._bindings); diff --git a/website/src/react-luma/components/luma-example.tsx b/website/src/react-luma/components/luma-example.tsx index 28fba1296c..b068dbc265 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 @@ -85,6 +79,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': @@ -461,7 +465,7 @@ export function ReactExample

(props: ReactExampleProps

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

(props: ReactExampleProps

) { window.clearInterval(statsIntervalId); frameRateController.stop(); for (const statsWidget of statsWidgets) { + storeStatsWidgetCollapsedState(statsWidget); statsWidget.remove(); } statsPanelRef.current?.replaceChildren(); @@ -510,45 +515,40 @@ 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; let frameRateController: FrameRateController | null = null; 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, { @@ -601,10 +601,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 = () => { @@ -660,9 +657,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(); @@ -672,16 +667,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; @@ -723,7 +717,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'); }