@woosh/meep-engine 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,12 @@ While other engines (like Unity) struggle with slow query times and documentatio
35
35
  ---
36
36
 
37
37
  ## ⚡ High-Performance Rendering
38
- Meep implements a Clustered Forward+ rendering pipeline, allowing for scenes that traditional web engines choke on.
38
+ Meep 3 renders through **Shade**: a GPU-driven, visibility-buffer deferred renderer on **WebGPU**. Geometry is clustered into meshlets ahead of time and lives GPU-resident; culling runs on the GPU; lights are binned into froxels and applied deferred.
39
+
40
+ **→ [The renderer contract](src/shade/RENDERER_CONTRACT.md)** — what it promises, how to put a model on the screen, and how to add your own work to the frame.
41
+ **Coming from 2.x?** [Migration guide](MIGRATION_2_TO_3.md) · [Changelog](CHANGELOG.md).
42
+
43
+ * **WebGPU only.** There is no WebGL fallback, no backend abstraction and no degradation tiers. Hardware below the device floor gets a clean failure message rather than a lesser picture.
39
44
 
40
45
  * **Unlimited Lighting:** Render thousands of point lights (muzzle flashes, explosions, torches) with clustered lighting.
41
46
 
@@ -43,7 +48,9 @@ Meep implements a Clustered Forward+ rendering pipeline, allowing for scenes tha
43
48
 
44
49
  * **Massive Terrain:** Chunk-based, auto-culled terrain system supporting up to 256 layers (vs Unity's 4).
45
50
 
46
- * **"Particular" Particle Engine:** A zero-allocation particle system with full lighting, soft particles, and automatic atlassing. Compiled into just 4 shaders to eliminate state-switching overhead.
51
+ * **"Particular" Particle Engine:** A zero-allocation particle system with full lighting, soft particles, and automatic atlassing, drawn through the renderer's own billboard pipeline.
52
+
53
+ * **Extensible frames:** Your own work goes into the frame as a *render extension* — a declared phase, declared ordering, and typed per-frame records — rather than as a patch to the pipeline.
47
54
 
48
55
  * **Path Tracing:** Includes a pure JS path tracer utilizing the engine's internal BVH.
49
56
 
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "description": "Pure JavaScript game engine. Fully featured and production ready.",
7
7
  "type": "module",
8
8
  "author": "Alexander Goldring",
9
- "version": "3.0.2",
9
+ "version": "3.1.0",
10
10
  "exports": {
11
11
  "./build/*": "./build/*",
12
12
  "./src/*": "./src/*",
@@ -1,6 +1,6 @@
1
1
  export class GridPosition2TransformSystem extends System<any> {
2
2
  constructor();
3
- dependencies: (typeof Transform | typeof GridPosition | typeof GridPosition2Transform)[];
3
+ dependencies: (typeof Transform | typeof GridPosition2Transform | typeof GridPosition)[];
4
4
  components_used: ResourceAccessSpecification<typeof Transform>[];
5
5
  data: any[];
6
6
  mapPoint: (x: any, y: any, v3: any) => void;
@@ -24,7 +24,7 @@ export class GridPosition2TransformSystem extends System<any> {
24
24
  }
25
25
  import { System } from '../../ecs/System.js';
26
26
  import { Transform } from '../../ecs/transform/Transform.js';
27
- import GridPosition from '../position/GridPosition.js';
28
27
  import { GridPosition2Transform } from './GridPosition2Transform.js';
28
+ import GridPosition from '../position/GridPosition.js';
29
29
  import { ResourceAccessSpecification } from "../../../core/model/ResourceAccessSpecification.js";
30
30
  //# sourceMappingURL=GridPosition2TransformSystem.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"GridPosition2TransformSystem.d.ts","sourceRoot":"","sources":["../../../../../src/engine/grid/grid2transform/GridPosition2TransformSystem.js"],"names":[],"mappings":"AAWA;IAOI,cASC;IAfD,yFAAiE;IACjE,iEAEE;IACF,YAAU;IAKN,4CAKC;IAGL;;;;;;OAMG;IACH,gBALW,sBAAsB,MACtB,YAAY,aACZ,SAAS,UACT,MAAM,QAgChB;IAED;;;;;;OAMG;IACH,kBALW,sBAAsB,MACtB,YAAY,aACZ,SAAS,UACT,MAAM,QAQhB;IAED,cAeC;CACJ;uBAhGsB,qBAAqB;0BAElB,kCAAkC;yBACnC,6BAA6B;uCACf,6BAA6B;4CALxB,oDAAoD"}
1
+ {"version":3,"file":"GridPosition2TransformSystem.d.ts","sourceRoot":"","sources":["../../../../../src/engine/grid/grid2transform/GridPosition2TransformSystem.js"],"names":[],"mappings":"AAWA;IAOI,cASC;IAfD,yFAAiE;IACjE,iEAEE;IACF,YAAU;IAKN,4CAKC;IAGL;;;;;;OAMG;IACH,gBALW,sBAAsB,MACtB,YAAY,aACZ,SAAS,UACT,MAAM,QAgChB;IAED;;;;;;OAMG;IACH,kBALW,sBAAsB,MACtB,YAAY,aACZ,SAAS,UACT,MAAM,QAQhB;IAED,cAeC;CACJ;uBAhGsB,qBAAqB;0BAElB,kCAAkC;uCAErB,6BAA6B;yBAD3C,6BAA6B;4CAJV,oDAAoD"}
@@ -64,11 +64,11 @@ export class FluidObstacleSystem extends System<any> {
64
64
  /**
65
65
  * @param {FluidComponent} fluid
66
66
  */
67
- static "__#349@#refresh_masks"(fluid: FluidComponent): void;
67
+ static "__#348@#refresh_masks"(fluid: FluidComponent): void;
68
68
  /**
69
69
  * @param {FluidComponent} fluid
70
70
  */
71
- static "__#349@#clear_field"(fluid: FluidComponent): void;
71
+ static "__#348@#clear_field"(fluid: FluidComponent): void;
72
72
  /**
73
73
  * Mark every cell of `fluid` whose centre lies within `inflation` of the
74
74
  * posed shape as solid. Iteration is clipped to the shape's world AABB
@@ -80,7 +80,7 @@ export class FluidObstacleSystem extends System<any> {
80
80
  * @param {number} inflation world-units SDF threshold
81
81
  * @param {Float64Array} point length-3 scratch
82
82
  */
83
- static "__#349@#voxelize"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array): void;
83
+ static "__#348@#voxelize"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array): void;
84
84
  /**
85
85
  * Write the obstacle's translation velocity onto every face of every cell
86
86
  * it voxelized — the moving-wall boundary condition. Runs AFTER the mask
@@ -100,7 +100,7 @@ export class FluidObstacleSystem extends System<any> {
100
100
  * @param {number} wvy
101
101
  * @param {number} wvz
102
102
  */
103
- static "__#349@#stamp_wall_velocity"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array, wvx: number, wvy: number, wvz: number): void;
103
+ static "__#348@#stamp_wall_velocity"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array, wvx: number, wvy: number, wvz: number): void;
104
104
  constructor();
105
105
  dependencies: (typeof FluidObstacle)[];
106
106
  components_used: (ResourceAccessSpecification<typeof Transform> | ResourceAccessSpecification<typeof RigidBody> | ResourceAccessSpecification<typeof Collider> | ResourceAccessSpecification<typeof FluidComponent> | ResourceAccessSpecification<typeof FluidObstacle>)[];
@@ -0,0 +1,568 @@
1
+ # Shade — the renderer contract
2
+
3
+ meep 3.0 has one renderer, and this is what it promises.
4
+
5
+ It is written for somebody who has never seen the inside of it: by the end of §3 you have a model on
6
+ the screen, and by the end of §6 you have your own work in the frame. Everything here is the public
7
+ surface — the types you construct, the calls you make, and the two or three places where the answer
8
+ is "no, and here is why".
9
+
10
+ If you are looking for how it works rather than what it promises, the design documents are next to
11
+ the code they describe: `renderer/extension/RENDER_EXTENSION_DESIGN.md`,
12
+ `renderer/geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md`, `renderer/geometry/meshlet/README.md`,
13
+ `renderer/NOTES.md`.
14
+
15
+ ---
16
+
17
+ ## 1. What Shade is
18
+
19
+ A **GPU-driven, visibility-buffer deferred renderer on WebGPU.** Geometry is clustered into meshlets
20
+ ahead of time and lives in one large GPU buffer; culling runs on the GPU; the rasterizer writes a
21
+ visibility buffer and a G-buffer rather than shading as it draws; lights are binned into froxels and
22
+ applied deferred; and the result is temporally resolved, upscaled, tonemapped and presented.
23
+
24
+ Three consequences that shape everything below:
25
+
26
+ - **A mesh is a small GPU-resident record, not a draw call you own.** You add a `Mesh` to a `Scene`;
27
+ what is drawn, in what order, and whether it is culled is the renderer's.
28
+ - **Preparing geometry is a precompute.** Meshlet building, encoding and BVH construction happen
29
+ once per geometry. Content that rewrites its vertices every frame goes down a separate path (§8).
30
+ - **The frame is a closed sequence with declared injection points.** You do not insert a pass
31
+ anywhere; you register an extension against a phase (§6).
32
+
33
+ ### What is deliberately absent
34
+
35
+ | | |
36
+ |---|---|
37
+ | **No RHI, no backend abstraction** | There is one target, WebGPU. There is no seam where a second one would go, and adding one is not a small change. |
38
+ | **No WebGL fallback** | Below-floor hardware gets a clean failure message (§2), not a degraded picture. |
39
+ | **No degradation tiers** | There is one pipeline. Individual features can be switched off (`feature_shadows_enabled`, `feature_taa_enabled`, the resolution scale), but there is no "low quality" build of the frame. |
40
+ | **No double-sided drawing** | Back faces are always culled. `ShadeMaterial.draw_side` records intent and does not change what is drawn; a surface that must be visible from behind needs its geometry cloned with flipped normals. |
41
+ | **No compressed textures** | DDS, Draco and Basis are not supported anywhere. glTF files carrying `MSFT_texture_dds` load from their base PNG source. |
42
+
43
+ ---
44
+
45
+ ## 2. The device floor
46
+
47
+ Shade requires a WebGPU device that meets a fixed floor. There is no feature detection beyond it and
48
+ no branch that produces a lesser frame.
49
+
50
+ **Required features** — the adapter must have both, or startup fails:
51
+
52
+ - `indirect-first-instance` — indirect draw is used throughout
53
+ - `float32-blendable` — order-independent transparency
54
+
55
+ **Required limits:**
56
+
57
+ - `maxStorageBuffersPerShaderStage` ≥ **10** (checked against the adapter before the device is
58
+ requested, so the failure names the number the adapter offered)
59
+ - `maxColorAttachmentBytesPerSample` ≥ **32** — the G-buffer is wide
60
+ - `maxBufferSize` and `maxStorageBufferBindingSize` are requested at whatever the adapter offers,
61
+ because the defaults are far too small
62
+
63
+ **Taken when offered, never required:** `timestamp-query`, `subgroups`, `texture-formats-tier1`.
64
+ `timestamp-query` drives GPU timers and nothing else, and some browsers withhold it on hardware that
65
+ is otherwise fine — so the timers go quiet when it is absent rather than the renderer refusing to
66
+ start.
67
+
68
+ ### When it is not met
69
+
70
+ `Renderer.initialize()` throws a `ShadeDeviceFailure`, whose `message` is written to be shown to a
71
+ person rather than logged. It distinguishes:
72
+
73
+ | | |
74
+ |---|---|
75
+ | `webgpu_unavailable` | no `navigator.gpu` — WebGPU disabled or unsupported |
76
+ | `adapter_unavailable` | no adapter at all |
77
+ | `below_floor` | an adapter that does not meet §2, with the detail |
78
+ | `device_request_failed` | the adapter refused the device |
79
+ | `device_lost` | a working device went away while running |
80
+
81
+ A device lost while running arrives on `Renderer.onDeviceFailure` instead of being thrown. **Shade
82
+ does not recover a lost device.** Everything the renderer built lived on it, and the honest response
83
+ is a message and a reload. The signal exists so an application can say so and save what it has.
84
+
85
+ ---
86
+
87
+ ## 3. Putting a model on the screen
88
+
89
+ meep has no module aliases: every import is a relative path, and the ones below are written from
90
+ `src/shade/` so they read like the tree in §10.
91
+
92
+ ```js
93
+ import { Renderer } from "./renderer/Renderer.js";
94
+ import { Scene } from "./renderer/scene/Scene.js";
95
+ import { PerspectiveCamera } from "./renderer/camera/PerspectiveCamera.js";
96
+ import { make_sunlight } from "./renderer/light/make_sunlight.js";
97
+ import { load_gltf } from "./renderer/loader/gltf/load_gltf.js";
98
+ import { create_frame_loop } from "./renderer/create_frame_loop.js";
99
+ import { DEG_TO_RAD } from "../core/math/DEG_TO_RAD.js";
100
+ import Vector3 from "../core/geom/Vector3.js";
101
+
102
+ const canvas = document.querySelector("canvas");
103
+
104
+ const renderer = new Renderer();
105
+
106
+ await renderer.initialize({ context: canvas.getContext("webgpu") });
107
+
108
+ const camera = new PerspectiveCamera();
109
+
110
+ camera.transform.position.set(0, 2, -5);
111
+ camera.transform.lookAt(Vector3.zero);
112
+ camera.near = 0.1;
113
+ camera.far = 200;
114
+ camera.fov = 72 * DEG_TO_RAD;
115
+ camera.aspect = renderer.aspect_ratio;
116
+
117
+ const scene = new Scene();
118
+
119
+ // nothing is lit without one, and the deferred shading pass has no default light
120
+ scene.lights.add(make_sunlight({ intensity: 2.2 }));
121
+
122
+ const bundle = await load_gltf("model.glb", { assetManager });
123
+
124
+ scene.add(bundle.scenes);
125
+
126
+ create_frame_loop(dt => renderer.render(camera, scene, dt));
127
+ ```
128
+
129
+ That is the whole of it. Five things in it are worth stating outright.
130
+
131
+ **`initialize` is asynchronous and may fail normally.** Acquiring a WebGPU device has no synchronous
132
+ form and being refused one is an ordinary outcome, so it is a rejected promise carrying a
133
+ `ShadeDeviceFailure` rather than a constructor that throws. Omit `context` and a full-viewport canvas
134
+ is created for you; pass a `device` to share one you already have.
135
+
136
+ **`assetManager` is required, and here is where one comes from.** A glTF is several files — a
137
+ container, its `.bin` buffers, its images — and which of them are already in memory is not the
138
+ parser's business to know. Every request the parse makes goes back through the manager, so they are
139
+ cached, counted and cancellable like anything else the application loads. Two loaders are all
140
+ `load_gltf` asks for:
141
+
142
+ ```js
143
+ const assetManager = new AssetManager({ context: {}, executor: new ConcurrentExecutor() });
144
+
145
+ await assetManager.registerLoader(GameAssetType.ArrayBuffer, new ArrayBufferLoader());
146
+ await assetManager.registerLoader(GameAssetType.ImageBitmap, new ImageBitmapAssetLoader());
147
+
148
+ assetManager.startup();
149
+ ```
150
+
151
+ Pass a `scope` alongside it to tie a whole load to one cancellable unit, and a `fileMap` when the
152
+ files come from a drop rather than from the network. An application already running meep's `Engine`
153
+ has a configured manager on it and should use that one.
154
+
155
+ **A model does not have to come from a file.** Everything `load_gltf` produces you can build
156
+ directly, which is §4 in the other direction and needs no asset manager at all:
157
+
158
+ ```js
159
+ const mesh = new Mesh();
160
+
161
+ mesh.geometry = meshlet_geometry_build_from_geometry(make_torus_knot_geometry(1, 0.3, 128, 16));
162
+ mesh.material = new StandardShadeMaterial();
163
+
164
+ mesh.transform_local.setTranslation(0, 1, 0);
165
+ mesh.updateMatrices();
166
+
167
+ scene.add(mesh);
168
+ ```
169
+
170
+ **`render` returns a boolean.** `false` means the previous frame has not finished and this one was
171
+ not submitted. It is not an error.
172
+
173
+ **`scene.add` takes a node or an array of them, and it recurses.** Nodes tagged `isLight` are routed
174
+ to `scene.lights`, everything else into `scene.instances`; `scene.remove` mirrors it exactly.
175
+
176
+ ---
177
+
178
+ ## 4. The type model
179
+
180
+ Everything in a scene is a **`Node3D`**: a TRS transform, a parent and children, and a row in the GPU
181
+ scene-graph. `Scene` is itself a `Node3D` and occupies row 0, so posing the scene world-shifts
182
+ everything under it.
183
+
184
+ **A node is placed through `transform_local`, and everything else about where it is, is derived.**
185
+ `transform_global` is recomputed from the local transform and the parent chain by `updateMatrices()`,
186
+ which is also what refreshes a `Mesh`'s world-space bounds — so writing `transform_global` directly is
187
+ undone the moment anything refreshes, and mutating `transform_local` without calling
188
+ `updateMatrices()` leaves both the global transform and the bounds stale. A version counter on the
189
+ node records the refresh, and the renderer compares against it.
190
+
191
+ **A node's transform and a camera's are different types, deliberately, and this is the one place a
192
+ newcomer will trip.** `Node3D.transform_local` is a `Transform64`: a flat `Float64Array` holding its
193
+ own matrix, posed through `setTranslation(x, y, z)` / `setRotation(x, y, z, w)` / `setScale(x, y, z)`,
194
+ with the buffer layout part of its contract so hot code can write it directly. A `Camera` is **not**
195
+ a `Node3D` — it carries meep's ECS `Transform`, whose `position`, `rotation` and `scale` are
196
+ observable vectors you assign into. The two keep distinct shapes on purpose and neither has the
197
+ other's accessors: `node.transform_local.position` is `undefined`, and so is
198
+ `camera.transform.translation`.
199
+
200
+ ```js
201
+ node.transform_local.setTranslation(0, 1, 0); // Transform64 — then updateMatrices()
202
+ camera.transform.position.set(0, 2, -5); // Transform — reactive, no refresh call
203
+ ```
204
+
205
+ ```
206
+ Scene (Node3D, row 0)
207
+ ├── lights LightCollection — their own pipeline, not in the instance batch
208
+ └── instances InstanceBatch
209
+ ├── nodes every Node3D ever added, in insertion order
210
+ └── instances the Mesh-bearing subset, which is what the rasterizer iterates
211
+ ```
212
+
213
+ A **`Mesh`** is a `Node3D` plus two fields:
214
+
215
+ ```js
216
+ mesh.geometry // MeshletGeometry — what to draw
217
+ mesh.material // ShadeMaterial — how to draw it
218
+ ```
219
+
220
+ and a world-space `bounding_box` / `bounding_sphere` the renderer keeps for culling.
221
+
222
+ ### Geometry: two types, one of which is a precompute
223
+
224
+ **`Geometry`** is the authoring form: named `Attribute`s over typed arrays, an optional index, a
225
+ `bounding_box`/`bounding_sphere` computed on demand by `ensureBounds()`, and a `version` you bump
226
+ through `needsUpdate = true` when you rewrite the arrays. Attributes are read by name:
227
+
228
+ ```js
229
+ geometry.getAttribute(StandardAttributes.Position).data // Float32Array
230
+ geometry.index.data // Uint32Array
231
+ ```
232
+
233
+ Shade ships seven generators, all returning a `Geometry`, in `renderer/geometry/primitives/`:
234
+ `make_box_geometry`, `make_plane_geometry`, `make_polyhedron_geometry`, `make_octahedron_geometry`,
235
+ `make_torus_geometry`, `make_cylinder_geometry`, `make_torus_knot_geometry`.
236
+
237
+ **`MeshletGeometry`** is the GPU form: triangles clustered into meshlets, attributes encoded, and a
238
+ BVH over the clusters. This is what a `Mesh` carries, and it is what buys cluster culling and a
239
+ compact residency.
240
+
241
+ ```js
242
+ const meshlets = meshlet_geometry_build_from_geometry(geometry);
243
+ ```
244
+
245
+ **That build is a precompute.** Do it once, when the content is created or loaded. If your vertices
246
+ change every frame, you are on the wrong path — see §8.
247
+
248
+ ### Materials
249
+
250
+ `ShadeMaterial` is the base: `transparency_mode`, `draw_mode`, `draw_side` (which records intent and
251
+ does not change what is drawn — see §1). `StandardShadeMaterial` is the PBR one everything uses:
252
+
253
+ | | |
254
+ |---|---|
255
+ | `texture_albedo` | base colour, alpha carries transparency |
256
+ | `diffuse_color` | multiplied with the albedo |
257
+ | `texture_normal` | tangent-space normals |
258
+ | `texture_orm` | occlusion / roughness (G) / metalness (B), as glTF packs it |
259
+ | `texture_emissive`, `emissive_factor` | |
260
+ | `roughness_factor`, `metallic_factor` | multiplied with the ORM texture |
261
+ | `transmission_factor`, `ior_factor` | |
262
+ | `ambient_factors` | a `LinearModifier` over the indirect contribution |
263
+ | `vt_stack` | opt-in virtual texturing; when set, albedo/normal/ORM come from streamed pages instead. Opaque materials only |
264
+
265
+ ### Textures
266
+
267
+ Two objects, and the split matters: **`ShadeImage`** is the pixels, **`ShadeTexture`** is how to
268
+ sample them. Two materials sampling one image differently are two textures over one image, and the
269
+ image is uploaded once.
270
+
271
+ ```js
272
+ const image = ShadeImage.fromImageBitmap(bitmap); // or fromSampler2D, or fromArrayBuffer
273
+ const texture = ShadeTexture.from(image);
274
+
275
+ texture.wrapS = TextureWrapType.Repeat;
276
+ texture.magFilter = TextureFilterType.Linear;
277
+ ```
278
+
279
+ `ShadeTexture` carries filters, wrap modes and flags (mipmap generation is on by default), and both
280
+ types answer `equals`/`hash`, which is what lets the renderer de-duplicate uploads.
281
+
282
+ ### What a glTF becomes
283
+
284
+ `load_gltf` returns a **`SceneBundle`** — three arrays out of the one file:
285
+
286
+ ```js
287
+ bundle.scenes // Node3D[] — the scene roots, ready for scene.add
288
+ bundle.skins // Skin[] — for GPUAnimationManager.register_skin
289
+ bundle.clips // ShadeAnimationClip[] — for register_clip
290
+ ```
291
+
292
+ The bundle is **the model as loaded, not a placement of it.** Adding one bundle's roots to two
293
+ scenes, or twice to one scene, shares nodes. meep's ECS layer provides
294
+ `instantiate_scene_bundle(bundle)` for the copy a second placement needs — its own nodes, its own
295
+ skin and its own retargeted clips — which is what makes two of one model possible.
296
+
297
+ ---
298
+
299
+ ## 5. The frame
300
+
301
+ ```
302
+ FrameStart view updated, nothing rasterized
303
+
304
+ [ visibility buffer + G-buffer ]
305
+ AfterGBuffer surface properties written and readable, nothing lit
306
+
307
+ [ clustered lighting, deferred shading, volumetrics ]
308
+ AfterLighting opaque lit and composited
309
+
310
+ [ transparency ]
311
+ AfterTransparency the complete scene, HDR, internal resolution
312
+
313
+ [ TAA / NSS upscale ] ← resolution changes here
314
+ BeforePresent HDR, output resolution, immediately before tonemapping
315
+
316
+ [ tonemap → canvas ]
317
+ Overlay display space, drawing onto the canvas
318
+ ```
319
+
320
+ **The upscale is a phase boundary and it is the one thing about the frame you cannot assume away.**
321
+ Three phases are before it and two are after. With `feature_taa_enabled === false` there is no
322
+ upscale and the two sides are the same size. Nothing in this document states a resolution as a
323
+ constant: ask `frame.resolution`, which is derived from the live handle and is right in every
324
+ configuration, including under dynamic resolution scaling.
325
+
326
+ ---
327
+
328
+ ## 6. Adding your own work to the frame — render extensions
329
+
330
+ An extension is **a named object with declarations and one method**.
331
+
332
+ ```js
333
+ import { RenderExtension } from "./renderer/extension/RenderExtension.js";
334
+ import { FramePhase } from "./renderer/extension/FramePhase.js";
335
+ import { SceneColor } from "./renderer/extension/SceneColor.js";
336
+ import { ViewTextures } from "./renderer/extension/ViewTextures.js";
337
+
338
+ class TrailExtension extends RenderExtension {
339
+ name = "trails";
340
+
341
+ phase = FramePhase.AfterTransparency;
342
+
343
+ record(frame) {
344
+ if (this.#batch.count === 0) {
345
+ // nothing to draw, and the frame's colour is left exactly as it was
346
+ return;
347
+ }
348
+
349
+ const scene = frame.get(SceneColor);
350
+
351
+ scene.color = this.#renderer.graph_draw({
352
+ graph: frame.graph,
353
+ batch: this.#batch,
354
+ color: scene.color,
355
+ depth: frame.get(ViewTextures).depth,
356
+ camera: frame.view.camera.buffer
357
+ });
358
+ }
359
+ }
360
+
361
+ const registered = renderer.add_extension(new TrailExtension());
362
+ // ...
363
+ renderer.remove_extension(registered);
364
+ ```
365
+
366
+ That is the whole mechanism. The rest of this section is what the four lines of it mean.
367
+
368
+ ### Records are the channel
369
+
370
+ `record(frame)` **returns nothing.** Everything an extension exchanges with the frame goes through
371
+ typed **records**: you read a frame-graph handle out of one and write a replacement back, and
372
+ **writing a field is publication** — everything after you carries what you left.
373
+
374
+ There is no privileged resource. The scene colour is published exactly the way a G-buffer target is,
375
+ and exactly the way a record of your own is.
376
+
377
+ | record | carried at | writable |
378
+ |---|---|---|
379
+ | `SceneColor` — `.color`, HDR | `AfterLighting` … `BeforePresent` | yes |
380
+ | `GBufferTextures` — `.albedo` (AO in alpha), `.normal`, `.pbr`, `.emissive` | `AfterGBuffer` … `BeforePresent` | **`AfterGBuffer` only** |
381
+ | `ViewTextures` — `.depth`, `.depth_previous`, `.visibility_mesh`, `.visibility_triangle` | `AfterGBuffer` … `BeforePresent` | yes |
382
+ | `PresentTarget` — `.canvas` | `Overlay` only | yes |
383
+
384
+ Three of those rows are the whole reason phases exist:
385
+
386
+ - **`SceneColor` is not carried at `Overlay`.** A texture may not be a render attachment and a
387
+ sampled source in the same pass, so at `Overlay` the canvas is written and cannot be read. An
388
+ effect that needs the finished image as *input* — a colour grade, a full-screen re-composite —
389
+ belongs at `BeforePresent`. You find that out from `frame.get(SceneColor)`, which fails with a
390
+ sentence naming the phases that do carry it, rather than from a `-1` failing inside a binding
391
+ builder.
392
+ - **`GBufferTextures` is in/out at `AfterGBuffer` and frozen afterwards.** Changing surface
393
+ properties means reading the targets, writing replacements and publishing the new handles — two of
394
+ the four are integer formats and cannot be blended, and nothing can be attachment and source at
395
+ once. It stays *readable* to `BeforePresent` because outline and selection effects legitimately
396
+ read it late; at that phase it is on the far side of the upscale and is therefore **a different
397
+ size from the colour beside it**. `frame.describe(handle)` is how a pass working across that
398
+ boundary learns the ratio.
399
+ - **`PresentTarget` has one field and is not called `SceneColor` on purpose.** There is no colour
400
+ there to be tempted by. Keep the existing contents with a `load` op, write only your own pixels,
401
+ and publish the result back — that is what lets two overlay extensions compose.
402
+
403
+ ### Extending the vocabulary
404
+
405
+ An extension can publish a record of its own for another extension to read, and doing so costs no
406
+ edit to Shade:
407
+
408
+ ```js
409
+ class VisibilityGrid extends FrameRecord {
410
+ #grid = -1;
411
+
412
+ static is_present_at(phase) { return true; }
413
+
414
+ get grid() { return this.read(this.#grid); }
415
+ set grid(handle) { this.#grid = this.publish(handle); }
416
+ }
417
+
418
+ // at FrameStart
419
+ frame.create(VisibilityGrid).grid = record_the_grid_update(frame.graph);
420
+
421
+ // at AfterTransparency, in another extension
422
+ if (frame.has(VisibilityGrid)) { … frame.get(VisibilityGrid).grid … }
423
+ ```
424
+
425
+ `has` / `get` / `create` are three calls rather than one `get_or_create` because **absent has to stay
426
+ distinguishable from empty.**
427
+
428
+ ### Ordering
429
+
430
+ Order is **declared**, never emergent:
431
+
432
+ ```js
433
+ class DecalExtension extends RenderExtension {
434
+ phase = FramePhase.AfterGBuffer;
435
+ after = [TerrainSplatExtension]; // classes, not instances
436
+ before = [];
437
+ }
438
+ ```
439
+
440
+ Constraints name classes, so you do not need a reference to another system's object to state an
441
+ order, and a class nobody registered is simply not a constraint. Two extensions on one phase with no
442
+ path between them record in **ascending `name` order** — reproducible rather than correct, and chosen
443
+ precisely so that adding an `await` to a system's startup cannot change the picture. A cycle is
444
+ reported with the names on it.
445
+
446
+ ### What keeps your work alive
447
+
448
+ The graph elides work whose output nothing consumes, and that applies to an extension exactly as it
449
+ does to a built-in pass. Thread a result into a record and it is consumed. Write something imported —
450
+ the canvas, an imported texture — and it is a side effect. Want work that runs regardless and
451
+ `make_side_effect()` on the pass builder says so.
452
+
453
+ **An extension whose whole recording is elided is a correct, cheaper frame, not a failure**, and
454
+ nothing reports it as one. It is a grey cluster with your extension's name on it in a graph dump.
455
+
456
+ ### The rest of `FrameContext`
457
+
458
+ | | |
459
+ |---|---|
460
+ | `frame.graph` | the frame's `FrameGraph`. Record into it with the `graph_*` helpers, exactly as the built-in passes do |
461
+ | `frame.view` | the `GPUViewContext` — camera, uniforms, frame index |
462
+ | `frame.phase` | which phase this is |
463
+ | `frame.resolution` | `[width, height]` of what you are drawing into, from the live handle |
464
+ | `frame.describe(handle)` | the descriptor of any resource, for a pass that has to relate two of them |
465
+ | `frame.is_open` | records are per-frame. A record held past the end of the frame is a retained handle by another name, and both reading and publishing through one say so |
466
+
467
+ ### Not built yet
468
+
469
+ Two pieces of the extension design are named in `RENDER_EXTENSION_DESIGN.md` and **do not exist**:
470
+ `RenderExtensionRegistry.describe()` (printing the pipeline as data) and `FrameProduct` (opting into
471
+ optional frame products such as velocity or a readable copy of the colour). Do not write against
472
+ them.
473
+
474
+ ---
475
+
476
+ ## 7. Rendering somewhere other than the canvas
477
+
478
+ ```js
479
+ import { GPUTextureContext } from "./renderer/texture/GPUTextureContext.js";
480
+
481
+ renderer.render_to_target(camera, scene, dt, GPUTextureContext.fromTexture(texture, renderer.device));
482
+ ```
483
+
484
+ `render()` *is* this call with the canvas's current texture as the target — the canvas is one target
485
+ among others, not a privileged one.
486
+
487
+ **The target says where the picture goes, not how big it is.** Everything the frame is built out of —
488
+ the G-buffer, the visibility buffer, the depth ping-pong, the temporal history — is sized from
489
+ `renderer.output_resolution`, so **the target must be that size**. A caller wanting a different one
490
+ calls `renderer.resize()` first and puts the size back afterwards.
491
+
492
+ This is what makes a frame readable at all: a canvas texture is configured by the presentation path
493
+ and cannot carry `COPY_SRC`; a texture you own can. Thumbnails, asset previews and image-based tests
494
+ all go through here.
495
+
496
+ ---
497
+
498
+ ## 8. Content whose vertices change — the dynamic-mesh path
499
+
500
+ A trail behind a projectile, a ribbon, a tube along a path, a batch of CPU-simulated particles, a
501
+ debug line: content rewritten every frame cannot pay for a meshlet build, and rebuilding meshlets per
502
+ frame to force it down the standard path is explicitly not the answer.
503
+
504
+ ```js
505
+ const mesh = DynamicMesh.from(geometry, [1, 0.5, 0, 1]); // Geometry, not MeshletGeometry
506
+
507
+ batch.add(mesh); // DynamicMeshBatch, yours — not the Scene's
508
+ mesh.visible = false; // hide without leaving the batch
509
+ geometry.needsUpdate = true; // you say when the vertices changed; nothing watches the arrays
510
+ ```
511
+
512
+ This is **a separate path, not a variant of the standard one.** A `DynamicMesh` holds a plain
513
+ `Geometry`, is never in the scene's instance batch, gets no row in the scene database, and is drawn
514
+ by a pipeline of its own after the standard geometry path has finished with the frame.
515
+
516
+ Drawing it is a `GPUDynamicMeshRenderer` inside an extension of yours (§6). Two things to know:
517
+
518
+ **There is one vertex layout and it is fixed** — 8 floats: position at `[0..2]`, one reserved slot at
519
+ `[3]`, RGBA colour at `[4..7]`. A dynamic mesh is drawn by a single pipeline, so letting each geometry
520
+ declare its own attribute set would buy nothing and cost either a pipeline per layout or a shader
521
+ that branches per vertex. Your geometry needs a position attribute and an index; a colour attribute is
522
+ optional and is multiplied by the mesh's flat `color`.
523
+
524
+ **`GPUDynamicMeshRenderer.opaque` chooses blending or depth**, and it is a property of the pass
525
+ rather than of a mesh — the whole batch moves together. Left `false` the content is blended and only depth-tests,
526
+ which is what effects want. Set it and the pass writes depth instead: a solid surface with no meshlet
527
+ build behind it has no depth ordering otherwise, and no CPU sort can give it one, because a painter's
528
+ order over triangles is wrong wherever two of them interpenetrate.
529
+
530
+ Residency is keyed on **the geometry**, not on batch membership, so a mesh that blinks off for a
531
+ frame or is removed and re-added keeps its buffers.
532
+
533
+ ---
534
+
535
+ ## 9. Frame-level switches
536
+
537
+ | | |
538
+ |---|---|
539
+ | `feature_shadows_enabled` | |
540
+ | `feature_taa_enabled` | when off, there is no upscale and the phases either side of it are the same size |
541
+ | `internal_resolution_scale` | render internally below output resolution; `DynamicResolutionScaling` drives it from frame time |
542
+ | `pixel_ratio`, `output_resolution`, `aspect_ratio`, `resize(x, y)` | |
543
+ | `motion_blur`, `dof`, `dof_unreal`, `nss`, `restir_di`, `path_tracer` | sub-objects with their own settings |
544
+ | `frame_count`, `add_debug_frame(n)` | |
545
+
546
+ ---
547
+
548
+ ## 10. Where things live
549
+
550
+ ```
551
+ shade/renderer/
552
+ ├── Renderer.js the whole surface in §2, §7, §9
553
+ ├── scene/ Scene, Node3D, Mesh, SkinnedMesh, DynamicMesh, InstanceBatch
554
+ ├── geometry/ Geometry, MeshletGeometry, the builders, primitives/
555
+ ├── material/ ShadeMaterial, StandardShadeMaterial
556
+ ├── texture/ ShadeTexture, GPUTextureContext, source/ShadeImage
557
+ ├── light/ LightCollection, make_sunlight, model/{Light,DirectionalLight,PointLight,SpotLight}
558
+ ├── camera/ PerspectiveCamera
559
+ ├── loader/gltf/ load_gltf, SceneBundle
560
+ ├── extension/ RenderExtension, FrameContext, FrameRecord, the four records
561
+ ├── dynamic/ GPUDynamicMeshRenderer and its one vertex layout
562
+ └── animation/ GPUAnimationManager — skins and clips
563
+ ```
564
+
565
+ `shade/device/mock/` holds a **validating software device**: no shader execution, but real validation
566
+ of bindings, passes, resources and texture copies. It is how Shade's own tests run without a GPU, and
567
+ it is available to yours. Specs must never acquire a real device — see `vitest.config.mjs`, which
568
+ explains at length why.