@driftengine/core 4.8.1 → 4.8.3

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.
Files changed (159) hide show
  1. package/CHANGELOG.json +142 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +8 -0
  5. package/dist/input/actionMap.d.ts +6 -1
  6. package/dist/input/actionMap.js +20 -7
  7. package/dist/input/input.d.ts +28 -0
  8. package/dist/input/input.js +71 -9
  9. package/dist/input/inputHarness.d.ts +9 -0
  10. package/dist/input/inputHarness.js +34 -2
  11. package/dist/render/backend/materialChanges.d.ts +7 -2
  12. package/dist/render/backend/materialChanges.js +8 -3
  13. package/dist/render/backend/webgl2/renderer.d.ts +75 -3
  14. package/dist/render/backend/webgl2/renderer.js +209 -22
  15. package/dist/render/backend/webgpu/buffers.d.ts +2 -0
  16. package/dist/render/backend/webgpu/buffers.js +2 -0
  17. package/dist/render/backend/webgpu/causticsPass.js +2 -1
  18. package/dist/render/backend/webgpu/depthPass.d.ts +13 -3
  19. package/dist/render/backend/webgpu/depthPass.js +4 -3
  20. package/dist/render/backend/webgpu/filmPass.js +2 -1
  21. package/dist/render/backend/webgpu/flatPass.d.ts +5 -0
  22. package/dist/render/backend/webgpu/flatPass.js +18 -8
  23. package/dist/render/backend/webgpu/linePass.js +2 -1
  24. package/dist/render/backend/webgpu/panelPass.js +2 -1
  25. package/dist/render/backend/webgpu/particlePass.js +2 -1
  26. package/dist/render/backend/webgpu/pipelineCache.d.ts +40 -8
  27. package/dist/render/backend/webgpu/pipelineCache.js +76 -9
  28. package/dist/render/backend/webgpu/plumePass.js +2 -1
  29. package/dist/render/backend/webgpu/postPass.d.ts +12 -2
  30. package/dist/render/backend/webgpu/postPass.js +19 -5
  31. package/dist/render/backend/webgpu/renderer.d.ts +66 -2
  32. package/dist/render/backend/webgpu/renderer.js +189 -38
  33. package/dist/render/backend/webgpu/sdfTextPass.js +2 -1
  34. package/dist/render/backend/webgpu/textPass.js +2 -1
  35. package/dist/render/backend/webgpu/waterPass.js +2 -1
  36. package/dist/render/backend/webgpu/windStreakPass.js +2 -1
  37. package/dist/render/boltBatch.js +2 -1
  38. package/dist/render/causticsRenderer.js +2 -1
  39. package/dist/render/cutoutCaster.d.ts +3 -1
  40. package/dist/render/cutoutCaster.js +4 -0
  41. package/dist/render/decalPass.js +2 -1
  42. package/dist/render/globalMediumPass.js +3 -2
  43. package/dist/render/lineBatch.js +5 -1
  44. package/dist/render/mapsWithoutUvs.d.ts +24 -0
  45. package/dist/render/mapsWithoutUvs.js +24 -0
  46. package/dist/render/mesh.d.ts +2 -0
  47. package/dist/render/mesh.js +3 -0
  48. package/dist/render/oitPass.js +2 -1
  49. package/dist/render/particleBatch.js +3 -2
  50. package/dist/render/particlePool.d.ts +21 -2
  51. package/dist/render/particlePool.js +72 -37
  52. package/dist/render/pass.d.ts +12 -0
  53. package/dist/render/plumeRenderer.js +3 -2
  54. package/dist/render/renderQuality.d.ts +16 -8
  55. package/dist/render/sceneCoverage.d.ts +54 -0
  56. package/dist/render/sceneCoverage.js +72 -0
  57. package/dist/render/sdfTextRenderer.js +2 -1
  58. package/dist/render/shaders/depth.js +3 -2
  59. package/dist/render/shaders/flat/glassTint.d.ts +28 -0
  60. package/dist/render/shaders/flat/glassTint.js +31 -0
  61. package/dist/render/shaders/flat/index.d.ts +12 -0
  62. package/dist/render/shaders/flat/index.js +13 -2
  63. package/dist/render/shaders/flat/main.d.ts +1 -1
  64. package/dist/render/shaders/flat/main.js +60 -25
  65. package/dist/render/shaders/flat/surfaceEffects.d.ts +1 -1
  66. package/dist/render/shaders/flat/surfaceEffects.js +6 -2
  67. package/dist/render/shaders/generated/depth.wgsl.d.ts +32 -12
  68. package/dist/render/shaders/generated/depth.wgsl.js +32 -12
  69. package/dist/render/shaders/generated/flat.wgsl.d.ts +89 -0
  70. package/dist/render/shaders/generated/flat.wgsl.js +127 -38
  71. package/dist/render/shaders/generated/line.wgsl.d.ts +1 -1
  72. package/dist/render/shaders/generated/line.wgsl.js +1 -1
  73. package/dist/render/shaders/generated/particle.wgsl.d.ts +3 -3
  74. package/dist/render/shaders/generated/particle.wgsl.js +3 -3
  75. package/dist/render/shaders/generated/rush.wgsl.d.ts +1 -1
  76. package/dist/render/shaders/generated/rush.wgsl.js +1 -1
  77. package/dist/render/shaders/generated/sdfText.wgsl.d.ts +1 -1
  78. package/dist/render/shaders/generated/sdfText.wgsl.js +1 -1
  79. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  80. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  81. package/dist/render/shaders/line.d.ts +1 -1
  82. package/dist/render/shaders/outputTransform.d.ts +14 -1
  83. package/dist/render/shaders/outputTransform.js +23 -1
  84. package/dist/render/shaders/particle.d.ts +3 -3
  85. package/dist/render/shaders/recon/resolve.wgsl.js +15 -1
  86. package/dist/render/shaders/rush.js +13 -2
  87. package/dist/render/shaders/sdfText.d.ts +1 -1
  88. package/dist/render/shaders/temporalResolve.js +12 -5
  89. package/dist/render/ssrPass.js +2 -1
  90. package/dist/render/surfaceTexture.d.ts +13 -0
  91. package/dist/render/surfaceTexture.js +4 -0
  92. package/dist/render/textRenderer.js +2 -1
  93. package/dist/render/vertexDefaults.js +1 -0
  94. package/dist/render/viewModel.d.ts +29 -0
  95. package/dist/render/viewModel.js +35 -0
  96. package/dist/render/waterRenderer.js +2 -1
  97. package/dist/render/windStreakRenderer.js +2 -1
  98. package/package.json +6 -6
  99. package/src/index.ts +22 -1
  100. package/src/input/actionMap.ts +23 -8
  101. package/src/input/input.ts +79 -9
  102. package/src/input/inputHarness.ts +27 -2
  103. package/src/render/backend/materialChanges.ts +8 -3
  104. package/src/render/backend/webgl2/renderer.ts +223 -29
  105. package/src/render/backend/webgpu/buffers.ts +5 -0
  106. package/src/render/backend/webgpu/causticsPass.ts +2 -1
  107. package/src/render/backend/webgpu/depthPass.ts +4 -3
  108. package/src/render/backend/webgpu/filmPass.ts +2 -1
  109. package/src/render/backend/webgpu/flatPass.ts +43 -26
  110. package/src/render/backend/webgpu/linePass.ts +2 -1
  111. package/src/render/backend/webgpu/panelPass.ts +2 -1
  112. package/src/render/backend/webgpu/particlePass.ts +2 -1
  113. package/src/render/backend/webgpu/pipelineCache.ts +82 -8
  114. package/src/render/backend/webgpu/plumePass.ts +2 -1
  115. package/src/render/backend/webgpu/postPass.ts +20 -5
  116. package/src/render/backend/webgpu/renderer.ts +191 -37
  117. package/src/render/backend/webgpu/sdfTextPass.ts +2 -1
  118. package/src/render/backend/webgpu/textPass.ts +2 -1
  119. package/src/render/backend/webgpu/waterPass.ts +2 -1
  120. package/src/render/backend/webgpu/windStreakPass.ts +2 -1
  121. package/src/render/boltBatch.ts +2 -1
  122. package/src/render/causticsRenderer.ts +2 -1
  123. package/src/render/cutoutCaster.ts +15 -2
  124. package/src/render/decalPass.ts +2 -1
  125. package/src/render/globalMediumPass.ts +3 -2
  126. package/src/render/lineBatch.ts +3 -1
  127. package/src/render/mapsWithoutUvs.ts +43 -0
  128. package/src/render/mesh.ts +3 -0
  129. package/src/render/oitPass.ts +2 -1
  130. package/src/render/particleBatch.ts +3 -2
  131. package/src/render/particlePool.ts +107 -56
  132. package/src/render/pass.ts +12 -0
  133. package/src/render/plumeRenderer.ts +3 -2
  134. package/src/render/renderQuality.ts +16 -8
  135. package/src/render/sceneCoverage.ts +78 -0
  136. package/src/render/sdfTextRenderer.ts +2 -1
  137. package/src/render/shaders/depth.ts +3 -2
  138. package/src/render/shaders/flat/glassTint.ts +48 -0
  139. package/src/render/shaders/flat/index.ts +25 -2
  140. package/src/render/shaders/flat/main.ts +60 -25
  141. package/src/render/shaders/flat/surfaceEffects.ts +6 -2
  142. package/src/render/shaders/generated/depth.wgsl.ts +32 -12
  143. package/src/render/shaders/generated/flat.wgsl.ts +127 -38
  144. package/src/render/shaders/generated/line.wgsl.ts +1 -1
  145. package/src/render/shaders/generated/particle.wgsl.ts +3 -3
  146. package/src/render/shaders/generated/rush.wgsl.ts +1 -1
  147. package/src/render/shaders/generated/sdfText.wgsl.ts +1 -1
  148. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  149. package/src/render/shaders/outputTransform.ts +24 -1
  150. package/src/render/shaders/recon/resolve.wgsl.ts +15 -1
  151. package/src/render/shaders/rush.ts +13 -2
  152. package/src/render/shaders/temporalResolve.ts +12 -5
  153. package/src/render/ssrPass.ts +2 -1
  154. package/src/render/surfaceTexture.ts +16 -0
  155. package/src/render/textRenderer.ts +2 -1
  156. package/src/render/vertexDefaults.ts +1 -0
  157. package/src/render/viewModel.ts +41 -0
  158. package/src/render/waterRenderer.ts +2 -1
  159. package/src/render/windStreakRenderer.ts +2 -1
@@ -3,6 +3,7 @@ import { WATER_BINDINGS, WATER_FRAG_WGSL, WATER_VERT_WGSL, } from '../../shaders
3
3
  import { PATCH_RESOLUTION, buildUnitSheet, buildWaterGrid } from '../../waterGrid.js';
4
4
  import { DEPTH_FORMAT } from './flatPass.js';
5
5
  import { shaderModule } from './shaderModules.js';
6
+ import { SCENE_ALPHA_COVERS } from '../../sceneCoverage.js';
6
7
  /**
7
8
  * The water pass: two sheets and a patch, displaced by a wave field in the vertex stage.
8
9
  *
@@ -148,7 +149,7 @@ export function waterPipeline(cache, device, layout) {
148
149
  format: cache.format,
149
150
  blend: {
150
151
  color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
151
- alpha: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
152
+ alpha: SCENE_ALPHA_COVERS,
152
153
  },
153
154
  },
154
155
  ],
@@ -3,6 +3,7 @@ import { WINDSTREAKS_BINDINGS, WIND_STREAK_FRAG_WGSL, WIND_STREAK_VERT_WGSL, } f
3
3
  import { buildWindStreakLattice } from '../../windStreakDraw.js';
4
4
  import { DEPTH_FORMAT } from './flatPass.js';
5
5
  import { shaderModule } from './shaderModules.js';
6
+ import { SCENE_ALPHA_COVERS } from '../../sceneCoverage.js';
6
7
  /**
7
8
  * The wind-streak pass: a lattice of debris that follows the camera and wraps.
8
9
  *
@@ -141,7 +142,7 @@ export function windStreakPipeline(cache, device, layout) {
141
142
  dstFactor: 'one-minus-src-alpha',
142
143
  operation: 'add',
143
144
  },
144
- alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
145
+ alpha: SCENE_ALPHA_COVERS,
145
146
  },
146
147
  },
147
148
  ],
@@ -1,6 +1,7 @@
1
1
  import { compileProgram, uniformLocations } from './shader.js';
2
2
  import { BOLT_FRAG, BOLT_VERT } from './shaders/bolt.js';
3
3
  import { buildSegmentQuads, expandBoltSegments } from './segmentQuads.js';
4
+ import { blendKeeping } from './sceneCoverage.js';
4
5
  /**
5
6
  * One draw call for a pool of electrical arcs.
6
7
  *
@@ -80,7 +81,7 @@ export class BoltBatch {
80
81
  if (segmentCount === 0)
81
82
  return;
82
83
  gl.enable(gl.BLEND);
83
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
84
+ blendKeeping(gl, gl.SRC_ALPHA, gl.ONE);
84
85
  gl.depthMask(false);
85
86
  gl.disable(gl.CULL_FACE);
86
87
  gl.bindVertexArray(this.vao);
@@ -3,6 +3,7 @@ import { CAUSTICS_FRAG, CAUSTICS_VERT } from './shaders/caustics.js';
3
3
  import { bindAtmosphere } from './atmosphere.js';
4
4
  import { buildSheets } from './surfaceSheet.js';
5
5
  import { seaStateForWind } from './seaState.js';
6
+ import { blendKeeping } from './sceneCoverage.js';
6
7
  /**
7
8
  * How far from the water the effect has died out.
8
9
  *
@@ -97,7 +98,7 @@ export class CausticsRenderer {
97
98
  gl.uniform1f(u['uWaveGain'] ?? null, sea.steepness);
98
99
  bindAtmosphere(gl, u, atmosphere, camera.position[1] ?? 0, underwaterEnabled);
99
100
  gl.enable(gl.BLEND);
100
- gl.blendFunc(gl.ONE, gl.ONE);
101
+ blendKeeping(gl, gl.ONE, gl.ONE);
101
102
  /*
102
103
  * No depth write, but depth *testing* stays on: the sheet sits a couple of
103
104
  * centimetres under the surface it lights, so geometry in front of it — the
@@ -12,12 +12,14 @@
12
12
  * reads it and binds before it asks again.
13
13
  */
14
14
  import type { SurfaceMaterial } from './surfaceTexture.ts';
15
- /** A cutout caster's texture, its cutoff and its UV scale. */
15
+ /** A cutout caster's texture, its cutoff and its UV scale and offset. */
16
16
  export interface CutoutCaster<Texture> {
17
17
  readonly albedo: Texture;
18
18
  readonly cutoff: number;
19
19
  readonly u: number;
20
20
  readonly v: number;
21
+ readonly uOffset: number;
22
+ readonly vOffset: number;
21
23
  }
22
24
  /** The cutout `material` casts, or null for an ordinary caster. */
23
25
  export declare function cutoutOf<Texture>(material: SurfaceMaterial<Texture> | null | undefined): CutoutCaster<Texture> | null;
@@ -3,6 +3,8 @@ const answer = {
3
3
  cutoff: 0,
4
4
  u: 1,
5
5
  v: 1,
6
+ uOffset: 0,
7
+ vOffset: 0,
6
8
  };
7
9
  /** The cutout `material` casts, or null for an ordinary caster. */
8
10
  export function cutoutOf(material) {
@@ -16,5 +18,7 @@ export function cutoutOf(material) {
16
18
  answer.cutoff = cutoff;
17
19
  answer.u = material.uScale ?? 1;
18
20
  answer.v = material.vScale ?? 1;
21
+ answer.uOffset = material.uOffset ?? 0;
22
+ answer.vOffset = material.vOffset ?? 0;
19
23
  return answer;
20
24
  }
@@ -4,6 +4,7 @@ import { DEPTH_01_TO_CLIP } from './lightVolumeDraw.js';
4
4
  import { compileProgram, uniformLocations } from './shader.js';
5
5
  import { DECAL_PROJECT_FRAG } from './shaders/decalProject.js';
6
6
  import { FULLSCREEN_VERT } from './shaders/fullscreen.js';
7
+ import { blendKeeping } from './sceneCoverage.js';
7
8
  /**
8
9
  * Drawn decals on WebGL2: one scissored triangle per mark, multiplied into the finished scene.
9
10
  *
@@ -54,7 +55,7 @@ export class DecalPass {
54
55
  gl.depthMask(false);
55
56
  gl.disable(gl.CULL_FACE);
56
57
  gl.enable(gl.BLEND);
57
- gl.blendFunc(gl.ZERO, gl.SRC_COLOR);
58
+ blendKeeping(gl, gl.ZERO, gl.SRC_COLOR);
58
59
  gl.enable(gl.SCISSOR_TEST);
59
60
  /* Unit 0, chosen rather than inherited: `bindTexture` binds to whichever unit is active, and
60
61
  the renderer leaves that wherever its last pass finished. `OitPass` records what leaving it
@@ -4,6 +4,7 @@ import { DEPTH_01_TO_CLIP } from './lightVolumeDraw.js';
4
4
  import { compileProgram, uniformLocations } from './shader.js';
5
5
  import { FULLSCREEN_VERT } from './shaders/fullscreen.js';
6
6
  import { MEDIUM_FRAG, MEDIUM_UPSAMPLE_FRAG } from './shaders/globalMedium.js';
7
+ import { blendCovering, blendTransmitting } from './sceneCoverage.js';
7
8
  /**
8
9
  * The global participating medium on WebGL2: a half-res march, then a depth-aware composite.
9
10
  *
@@ -200,7 +201,7 @@ export class GlobalMediumPass {
200
201
  gl.depthMask(false);
201
202
  gl.disable(gl.CULL_FACE);
202
203
  gl.enable(gl.BLEND);
203
- gl.blendFuncSeparate(gl.ONE, gl.SRC_ALPHA, gl.ZERO, gl.ONE);
204
+ blendTransmitting(gl, gl.ONE, gl.SRC_ALPHA);
204
205
  gl.useProgram(this.upsample);
205
206
  gl.bindVertexArray(this.vao);
206
207
  gl.activeTexture(gl.TEXTURE0);
@@ -220,7 +221,7 @@ export class GlobalMediumPass {
220
221
  gl.activeTexture(gl.TEXTURE0);
221
222
  gl.bindTexture(gl.TEXTURE_2D, null);
222
223
  /* Back to the blend every other pass in this renderer assumes it inherits. */
223
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
224
+ blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
224
225
  gl.disable(gl.BLEND);
225
226
  gl.depthMask(true);
226
227
  gl.enable(gl.DEPTH_TEST);
@@ -1,6 +1,7 @@
1
1
  import { compileProgram, uniformLocations } from './shader.js';
2
2
  import { LINE_FRAG, LINE_VERT } from './shaders/line.js';
3
3
  import { buildSegmentQuads, expandLineSegments } from './segmentQuads.js';
4
+ import { blendCovering, blendKeeping } from './sceneCoverage.js';
4
5
  /**
5
6
  * One draw call for a polyline with a real width — an audio waveform, a trail, a plotted
6
7
  * curve, anything that is a stroke rather than a surface.
@@ -96,7 +97,10 @@ export class LineBatch {
96
97
  if (segmentCount === 0)
97
98
  return;
98
99
  gl.enable(gl.BLEND);
99
- gl.blendFunc(gl.SRC_ALPHA, additive ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA);
100
+ if (additive)
101
+ blendKeeping(gl, gl.SRC_ALPHA, gl.ONE);
102
+ else
103
+ blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
100
104
  gl.depthMask(false);
101
105
  // The quad billboards toward the view direction the same way a bolt's does — see
102
106
  // `line.ts`'s own `cross(dir, view)` — so its winding can flip with the camera exactly
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Saying, once a mesh, that a material's maps cannot be read on it.
3
+ *
4
+ * **A mesh with no texture coordinates reads one constant coordinate**, so every map bound to it
5
+ * samples a single texel: an emissive map makes nothing glow, a normal map tilts nothing, and the
6
+ * frame is identical pixel for pixel with and without the material's images. Found when an example's
7
+ * burning logs never glowed — `MeshBuilder` writes no coordinates unless `build({ planarUvs: true })`
8
+ * asks — and nothing said why. One decision for both backends, each calling it from its draw.
9
+ *
10
+ * Once per mesh, held weakly: a mesh drawn every frame is said once, and a disposed one is
11
+ * forgotten. **What it gives up** is a material shared between a textured mesh and an untextured one
12
+ * on purpose, which is said once for the untextured one and is then quiet.
13
+ */
14
+ import type { SurfaceMaterial } from './surfaceTexture.ts';
15
+ /**
16
+ * Whether a material binds a map that is a picture: albedo, normal or emissive.
17
+ *
18
+ * **Not ORM**, because a one-colour ORM map is how a draw sets a constant roughness and metalness,
19
+ * and read at one texel it is exactly that constant — the materials example draws its spheres so.
20
+ * The three counted here are pictures nobody binds to be read at one point.
21
+ */
22
+ export declare function materialHasMaps(material: SurfaceMaterial<unknown> | null): boolean;
23
+ /** Warn the first time `mesh`, which has no coordinates, is drawn with a picture map bound. */
24
+ export declare function noteMapsWithoutUvs(mesh: object, hasUvs: boolean, materialHasMaps: boolean): void;
@@ -0,0 +1,24 @@
1
+ const told = new WeakSet();
2
+ /**
3
+ * Whether a material binds a map that is a picture: albedo, normal or emissive.
4
+ *
5
+ * **Not ORM**, because a one-colour ORM map is how a draw sets a constant roughness and metalness,
6
+ * and read at one texel it is exactly that constant — the materials example draws its spheres so.
7
+ * The three counted here are pictures nobody binds to be read at one point.
8
+ */
9
+ export function materialHasMaps(material) {
10
+ if (material === null)
11
+ return false;
12
+ return ((material.albedo ?? null) !== null ||
13
+ (material.normal ?? null) !== null ||
14
+ (material.emissive ?? null) !== null);
15
+ }
16
+ /** Warn the first time `mesh`, which has no coordinates, is drawn with a picture map bound. */
17
+ export function noteMapsWithoutUvs(mesh, hasUvs, materialHasMaps) {
18
+ if (hasUvs || !materialHasMaps || told.has(mesh))
19
+ return;
20
+ told.add(mesh);
21
+ console.warn('[driftengine] a mesh with no texture coordinates is drawn with an albedo, normal or emissive ' +
22
+ 'map, which it cannot read: every map samples one texel. Give the mesh `uvs` — ' +
23
+ '`MeshBuilder.build({ planarUvs: true })` writes them — or draw it without the maps.');
24
+ }
@@ -56,6 +56,8 @@ export declare class Mesh {
56
56
  readonly bounds: Bounds;
57
57
  /** Whether this mesh carries a rig, which decides which flat program draws it. */
58
58
  readonly isSkinned: boolean;
59
+ /** Whether it has texture coordinates, without which a material's maps read one texel. */
60
+ readonly hasUvs: boolean;
59
61
  /**
60
62
  * This mesh's morph deltas, or null for geometry that does not deform.
61
63
  *
@@ -123,6 +123,8 @@ export class Mesh {
123
123
  bounds = createBounds();
124
124
  /** Whether this mesh carries a rig, which decides which flat program draws it. */
125
125
  isSkinned;
126
+ /** Whether it has texture coordinates, without which a material's maps read one texel. */
127
+ hasUvs;
126
128
  /**
127
129
  * This mesh's morph deltas, or null for geometry that does not deform.
128
130
  *
@@ -171,6 +173,7 @@ export class Mesh {
171
173
  constructor(gl, data, dynamic = false, spread = false) {
172
174
  validateMeshData(data);
173
175
  this.spread = spread;
176
+ this.hasUvs = data.uvs !== undefined;
174
177
  boundsOfPositions(data.positions, this.bounds);
175
178
  this.positionFloats = data.positions.length;
176
179
  const vao = gl.createVertexArray();
@@ -1,6 +1,7 @@
1
1
  import { compileProgram, uniformLocations } from './shader.js';
2
2
  import { FULLSCREEN_VERT } from './shaders/fullscreen.js';
3
3
  import { OIT_RESOLVE_FRAG } from './shaders/oitResolve.js';
4
+ import { blendTransmitting } from './sceneCoverage.js';
4
5
  /**
5
6
  * Order-independent transparency on WebGL2: two targets, two passes over the same geometry, and a
6
7
  * resolve.
@@ -163,7 +164,7 @@ export class OitPass {
163
164
  gl.depthMask(false);
164
165
  gl.disable(gl.CULL_FACE);
165
166
  gl.enable(gl.BLEND);
166
- gl.blendFunc(gl.ONE_MINUS_SRC_ALPHA, gl.SRC_ALPHA);
167
+ blendTransmitting(gl, gl.ONE_MINUS_SRC_ALPHA, gl.SRC_ALPHA);
167
168
  gl.useProgram(this.program);
168
169
  gl.activeTexture(gl.TEXTURE0);
169
170
  gl.bindTexture(gl.TEXTURE_2D, this.accum);
@@ -1,6 +1,7 @@
1
1
  import { compileProgram, uniformLocations } from './shader.js';
2
2
  import { particleShaders } from './particleMaterial.js';
3
3
  import { PARTICLE_BLADES, PARTICLE_INDICES, PARTICLE_VERTS } from './shaders/particle.js';
4
+ import { blendCovering, blendKeeping } from './sceneCoverage.js';
4
5
  /** Corners of one blade, as a triangle-strip-friendly quad. */
5
6
  const CORNERS = [
6
7
  [-1, -1],
@@ -138,9 +139,9 @@ export class ParticleBatch {
138
139
  return;
139
140
  gl.enable(gl.BLEND);
140
141
  if (this.blend === 'additive')
141
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
142
+ blendKeeping(gl, gl.SRC_ALPHA, gl.ONE);
142
143
  else
143
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
144
+ blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
144
145
  gl.depthMask(false);
145
146
  gl.disable(gl.CULL_FACE);
146
147
  gl.bindVertexArray(this.vao);
@@ -19,7 +19,11 @@ import type { Vec3 } from '../math/color.ts';
19
19
  export interface ParticleInstances {
20
20
  /** World position, three floats each. */
21
21
  positions: Float32Array;
22
- /** Metres across, and radians of roll about the particle's own axis. */
22
+ /**
23
+ * Metres from the centre to an edge, a half-width: the quad spans twice this. Then radians of
24
+ * roll about the particle's own axis. This said metres across until 4.8.3, and a caller who took
25
+ * it at its word drew every particle twice as wide as meant.
26
+ */
23
27
  sizes: Float32Array;
24
28
  spins: Float32Array;
25
29
  /** Linear colour, three floats each. */
@@ -54,7 +58,7 @@ export interface ParticlePoolOptions {
54
58
  capacity: number;
55
59
  /** Seconds a particle lives. */
56
60
  lifeSec: number;
57
- /** Size at birth and at death, metres. */
61
+ /** Size at birth and at death: metres from the centre to an edge, so the quad is twice as wide. */
58
62
  sizeStart: number;
59
63
  sizeEnd: number;
60
64
  /** Colour at birth and at death. */
@@ -98,6 +102,15 @@ export declare class ParticlePool {
98
102
  * together.
99
103
  */
100
104
  private readonly seedOf;
105
+ /**
106
+ * Where each slot's particle sits in the drawn streams, or -1 where it sits nowhere.
107
+ *
108
+ * **What lets `emit` draw a particle the frame it is born.** The streams are compacted, so a
109
+ * slot's entry is wherever `update` last put it; an emit into a slot that has one overwrites
110
+ * that entry, and an emit into one that has none appends. Without it a new particle waited for
111
+ * the next `update` to be drawn at all.
112
+ */
113
+ private readonly drawnAt;
101
114
  private next;
102
115
  constructor(options: ParticlePoolOptions);
103
116
  /** How many particles are alive. */
@@ -119,6 +132,12 @@ export declare class ParticlePool {
119
132
  * particles cost nothing but the loop iteration.
120
133
  */
121
134
  update(dt: number): void;
135
+ /**
136
+ * One particle's entry in both drawn streams, at `t` of its life: what `update` writes for every
137
+ * live particle and `emit` for a new one, so the two cannot disagree about what a particle looks
138
+ * like.
139
+ */
140
+ private writeDrawn;
122
141
  /** Drop everything, for a respawn or a scene change. */
123
142
  clear(): void;
124
143
  }
@@ -38,6 +38,15 @@ export class ParticlePool {
38
38
  * together.
39
39
  */
40
40
  seedOf;
41
+ /**
42
+ * Where each slot's particle sits in the drawn streams, or -1 where it sits nowhere.
43
+ *
44
+ * **What lets `emit` draw a particle the frame it is born.** The streams are compacted, so a
45
+ * slot's entry is wherever `update` last put it; an emit into a slot that has one overwrites
46
+ * that entry, and an emit into one that has none appends. Without it a new particle waited for
47
+ * the next `update` to be drawn at all.
48
+ */
49
+ drawnAt;
41
50
  next = 0;
42
51
  constructor(options) {
43
52
  this.options = options;
@@ -60,6 +69,7 @@ export class ParticlePool {
60
69
  this.spin = new Float32Array(capacity);
61
70
  this.scale = new Float32Array(capacity).fill(1);
62
71
  this.seedOf = new Float32Array(capacity);
72
+ this.drawnAt = new Int32Array(capacity).fill(-1);
63
73
  }
64
74
  /** How many particles are alive. */
65
75
  get live() {
@@ -95,6 +105,18 @@ export class ParticlePool {
95
105
  this.spin[i] = (hash - Math.floor(hash)) * Math.PI * 2;
96
106
  this.scale[i] = sizeScale;
97
107
  this.seedOf[i] = seed;
108
+ /*
109
+ * Drawn now, at birth, rather than at the next `update`: into the entry this slot already
110
+ * has when it is taking over a particle that was drawn, and onto the end otherwise.
111
+ */
112
+ const held = this.drawnAt[i];
113
+ const at = held >= 0 ? held : this.particles.count;
114
+ this.writeDrawn(i, at, 0, x, y, z, vx, vy, vz);
115
+ if (held < 0) {
116
+ this.drawnAt[i] = at;
117
+ this.instances.count = at + 1;
118
+ this.particles.count = at + 1;
119
+ }
98
120
  }
99
121
  /**
100
122
  * Advance every particle and rebuild the instance buffer.
@@ -108,12 +130,15 @@ export class ParticlePool {
108
130
  let count = 0;
109
131
  for (let i = 0; i < this.age.length; i++) {
110
132
  const age = this.age[i];
111
- if (age === Infinity)
133
+ if (age === Infinity) {
134
+ this.drawnAt[i] = -1;
112
135
  continue;
136
+ }
113
137
  const life = this.life[i];
114
138
  const next = age + dt;
115
139
  if (next >= life) {
116
140
  this.age[i] = Infinity;
141
+ this.drawnAt[i] = -1;
117
142
  continue;
118
143
  }
119
144
  this.age[i] = next;
@@ -131,50 +156,60 @@ export class ParticlePool {
131
156
  this.x[i] = px;
132
157
  this.y[i] = py;
133
158
  this.z[i] = pz;
134
- const t = next / life;
135
- const size = (o.sizeStart + (o.sizeEnd - o.sizeStart) * t) * this.scale[i];
136
- const r = o.colorStart[0] + (o.colorEnd[0] - o.colorStart[0]) * t;
137
- const g = o.colorStart[1] + (o.colorEnd[1] - o.colorStart[1]) * t;
138
- const b = o.colorStart[2] + (o.colorEnd[2] - o.colorStart[2]) * t;
139
- /*
140
- * Colour carries the fade rather than an alpha channel: the instanced
141
- * path multiplies a tint into the base mesh and has no per-instance
142
- * opacity, and for an additive puff darkening to nothing *is* fading out.
143
- * It also means the effect needs no blend-state change.
144
- */
145
- const fade = 1 - t * t;
146
- writeInstance(this.instances, count, px, py, pz, size, this.spin[i], r * fade, g * fade, b * fade, 0, 0, 0);
147
- /*
148
- * The same particle for a material that has a real opacity channel. Colour
149
- * is written *unfaded* here, because dimming it is the scatter path's
150
- * workaround and doing both would fade twice — a puff that vanished at half
151
- * its life while still occluding what was behind it.
152
- */
153
- const p = this.particles;
154
- const alphaStart = o.alphaStart ?? 1;
155
- const alphaEnd = o.alphaEnd ?? 0;
156
- p.positions[count * 3] = px;
157
- p.positions[count * 3 + 1] = py;
158
- p.positions[count * 3 + 2] = pz;
159
- p.sizes[count] = size;
160
- p.spins[count] = this.spin[i];
161
- p.colors[count * 3] = r;
162
- p.colors[count * 3 + 1] = g;
163
- p.colors[count * 3 + 2] = b;
164
- p.alphas[count] = alphaStart + (alphaEnd - alphaStart) * t;
165
- p.ages[count] = t;
166
- p.seeds[count] = this.seedOf[i];
167
- p.velocities[count * 3] = vx;
168
- p.velocities[count * 3 + 1] = vy;
169
- p.velocities[count * 3 + 2] = vz;
159
+ this.drawnAt[i] = count;
160
+ this.writeDrawn(i, count, next / life, px, py, pz, vx, vy, vz);
170
161
  count++;
171
162
  }
172
163
  this.instances.count = count;
173
164
  this.particles.count = count;
174
165
  }
166
+ /**
167
+ * One particle's entry in both drawn streams, at `t` of its life: what `update` writes for every
168
+ * live particle and `emit` for a new one, so the two cannot disagree about what a particle looks
169
+ * like.
170
+ */
171
+ writeDrawn(i, count, t, px, py, pz, vx, vy, vz) {
172
+ const o = this.options;
173
+ const size = (o.sizeStart + (o.sizeEnd - o.sizeStart) * t) * this.scale[i];
174
+ const r = o.colorStart[0] + (o.colorEnd[0] - o.colorStart[0]) * t;
175
+ const g = o.colorStart[1] + (o.colorEnd[1] - o.colorStart[1]) * t;
176
+ const b = o.colorStart[2] + (o.colorEnd[2] - o.colorStart[2]) * t;
177
+ /*
178
+ * Colour carries the fade rather than an alpha channel: the instanced
179
+ * path multiplies a tint into the base mesh and has no per-instance
180
+ * opacity, and for an additive puff darkening to nothing *is* fading out.
181
+ * It also means the effect needs no blend-state change.
182
+ */
183
+ const fade = 1 - t * t;
184
+ writeInstance(this.instances, count, px, py, pz, size, this.spin[i], r * fade, g * fade, b * fade, 0, 0, 0);
185
+ /*
186
+ * The same particle for a material that has a real opacity channel. Colour
187
+ * is written *unfaded* here, because dimming it is the scatter path's
188
+ * workaround and doing both would fade twice — a puff that vanished at half
189
+ * its life while still occluding what was behind it.
190
+ */
191
+ const p = this.particles;
192
+ const alphaStart = o.alphaStart ?? 1;
193
+ const alphaEnd = o.alphaEnd ?? 0;
194
+ p.positions[count * 3] = px;
195
+ p.positions[count * 3 + 1] = py;
196
+ p.positions[count * 3 + 2] = pz;
197
+ p.sizes[count] = size;
198
+ p.spins[count] = this.spin[i];
199
+ p.colors[count * 3] = r;
200
+ p.colors[count * 3 + 1] = g;
201
+ p.colors[count * 3 + 2] = b;
202
+ p.alphas[count] = alphaStart + (alphaEnd - alphaStart) * t;
203
+ p.ages[count] = t;
204
+ p.seeds[count] = this.seedOf[i];
205
+ p.velocities[count * 3] = vx;
206
+ p.velocities[count * 3 + 1] = vy;
207
+ p.velocities[count * 3 + 2] = vz;
208
+ }
175
209
  /** Drop everything, for a respawn or a scene change. */
176
210
  clear() {
177
211
  this.age.fill(Infinity);
212
+ this.drawnAt.fill(-1);
178
213
  this.instances.count = 0;
179
214
  this.particles.count = 0;
180
215
  this.next = 0;
@@ -122,6 +122,18 @@ export type PassContext = {
122
122
  readonly pass: GPURenderPassEncoder;
123
123
  /** The frame's jitter, the same array `PrepareContext.jitter` held. */
124
124
  readonly jitter: Float32Array;
125
+ /**
126
+ * The colour format, depth format and sample count of the pass this draw lands in, which a
127
+ * pipeline set on it must match exactly.
128
+ *
129
+ * **Not always the device's.** `PassDevice` says what the frame is; a pass drawn after
130
+ * `endFrame` lands on the canvas, at the canvas's format and one sample, where the frame is
131
+ * the scene target at the scene's samples. A pass that is drawn in both places keeps a
132
+ * pipeline for each and chooses here; one built only from the device is refused by the other.
133
+ */
134
+ readonly format: GPUTextureFormat;
135
+ readonly depthFormat: GPUTextureFormat;
136
+ readonly samples: number;
125
137
  });
126
138
  /**
127
139
  * What a pass is handed once a frame, before the frame's own target exists.
@@ -3,6 +3,7 @@ import { compileProgram, uniformLocations } from './shader.js';
3
3
  import { bindAtmosphere } from './atmosphere.js';
4
4
  import { plumeShaders } from './plumeMaterial.js';
5
5
  import { buildPlumeGeometry } from './plumeGeometry.js';
6
+ import { blendCovering, blendKeeping } from './sceneCoverage.js';
6
7
  /* The shared count, so both backends step a plume's vertices the same way. */
7
8
  const VERTS_PER_PLUME = PLUME_VERTS;
8
9
  export class PlumeRenderer {
@@ -123,9 +124,9 @@ export class PlumeRenderer {
123
124
  bindAtmosphere(gl, u, atmosphere, atmosphereCameraY, underwaterEnabled);
124
125
  gl.enable(gl.BLEND);
125
126
  if (this.blend === 'additive')
126
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
127
+ blendKeeping(gl, gl.SRC_ALPHA, gl.ONE);
127
128
  else
128
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
129
+ blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
129
130
  gl.depthMask(false);
130
131
  gl.disable(gl.CULL_FACE);
131
132
  gl.bindVertexArray(this.vao);
@@ -14,8 +14,14 @@
14
14
  * `aces` adds filmic tone mapping before that, rolling highlights off instead of
15
15
  * clipping them flat. Together they are what three.js does by default, which is why a
16
16
  * world ported from it looks wrong until this exists.
17
+ *
18
+ * `shoulder` keeps sRGB's midtones exactly and eases only the highlights: past 0.8 the brightest
19
+ * channel rolls toward 1 and the other two are scaled with it, so an overbright red spark stays
20
+ * red where `srgb` clips each channel and turns its core pink and wide. It is the curve a ported
21
+ * game's own resolve applied — for a port that has to match one — and it has none of ACES's toe or
22
+ * desaturation. See `HIGHLIGHT_SHOULDER_GLSL` for the arithmetic.
17
23
  */
18
- export type OutputTransform = 'none' | 'srgb' | 'aces';
24
+ export type OutputTransform = 'none' | 'srgb' | 'aces' | 'shoulder';
19
25
  /**
20
26
  * The three settings whose accepted values are a closed set, written as one.
21
27
  *
@@ -60,7 +66,8 @@ export interface RenderQuality {
60
66
  * chooses is a look rather than a standard — three.js uses 1/0.6 and says so. This
61
67
  * engine holds no opinion and takes the number from whoever is authoring the world, so
62
68
  * that matching another renderer, or simply preferring a different grade, is a setting
63
- * rather than a patch. Ignored when `outputTransform` is not `aces`.
69
+ * rather than a patch. Scales into `aces` and into `shoulder`, the two curves; ignored by
70
+ * `none` and `srgb`, which have none.
64
71
  */
65
72
  readonly outputExposure: number;
66
73
  /**
@@ -185,12 +192,13 @@ export interface RenderQuality {
185
192
  * existed: a pane casts nothing at all.
186
193
  *
187
194
  * Nothing is allocated until a glass caster first reaches a shadow, so a scene with no glass pays
188
- * no memory and no pass whatever this says. **The lit shader is the exception**: `'full'` and
189
- * `'half'` compile the glass lookup into it, and a lookup that finds no glass and returns at its
190
- * first line still costs the lit pass its registers — measured on the courtyard at night, 0.27 ms
191
- * at 720p on WebGPU, 0.42 ms on WebGL2 and 1.2 ms at 4K. `'off'` compiles none of it, so a world
192
- * that never casts through glass is that much faster for saying so. What would make `'full'` wrong
193
- * is a phone budget with a lot of glass; that is what `'half'` is for. See `glassShadow.ts`.
195
+ * no memory and no pass whatever this says. **Nor any shader, since 4.8.2**: the lit shader is
196
+ * built without the glass lookup and rebuilt with it when the first glass caster reaches a shadow.
197
+ * A lookup that finds no glass still cost the lit pass its registers — measured on the courtyard at
198
+ * night, 0.27 ms at 720p on WebGPU, 0.42 ms on WebGL2 and 1.2 ms at 4K — and from 4.5.0 to 4.8.1
199
+ * every scene paid it, glass or none: about 30% of the lit shader's instructions, which on a phone
200
+ * was the lit pass. `'off'` never rebuilds. What would make `'full'` wrong is a phone budget with a
201
+ * lot of glass; that is what `'half'` is for. See `glassShadow.ts`.
194
202
  */
195
203
  readonly glassShadows: GlassShadows;
196
204
  /** Horizontal world-space reach over which a directional shadow dissolves. */
@@ -0,0 +1,54 @@
1
+ /**
2
+ * What a blended draw into the scene does to its alpha, which holds how much of each pixel is still
3
+ * the opaque surface — the share ambient occlusion is allowed to darken.
4
+ *
5
+ * **Occlusion is applied in the composite, from the depth the opaque world left**, after every
6
+ * translucent draw has landed, and a particle or a pane writes no depth: so a puff of smoke in front
7
+ * of a wall was darkened by the corner behind it. Reported from a game as walls and two figures
8
+ * standing behind a cloud showing through it as outlines, and nothing at all with occlusion off.
9
+ *
10
+ * So the scene's alpha is a running share. The frame clears it to 1 and an opaque draw writes 1,
11
+ * which is the whole of the surface; then each draw blended in front of it says which of three
12
+ * things it is, and the composite darkens `share` of the pixel and leaves the rest:
13
+ *
14
+ * - **It covers**: glass, smoke, water, a line, text in the world, a panel. The share falls by what
15
+ * the draw lets through, `share * (1 - a)`.
16
+ * - **It transmits**: the medium, the order-independent composite. Its alpha already *is* what gets
17
+ * through, `share * a`.
18
+ * - **It keeps**: light added to the surface or a layer of the surface itself — caustics, a light
19
+ * volume, a bolt, a wet film, a decal, a reflection. The share is unchanged.
20
+ *
21
+ * **What it gives up** is added light in front of an occluded corner: a glow or a spark adds to the
22
+ * pixel and keeps the share, so the composite darkens what it added along with the surface — which
23
+ * is how every blended draw behaved before this, and one multiply cannot tell the two apart. **What
24
+ * would make it wrong** is an opaque pass writing an alpha below 1: its surface would then take less
25
+ * occlusion than it should, which is the safe direction, and is the lit shader's own case at a
26
+ * cutout's edge or under a per-vertex alpha lane.
27
+ *
28
+ * **It survives the temporal passes.** A temporal resolve passes the frame's alpha through, since
29
+ * it is a fact about this frame and takes no history; a reconstruction reads it from the scene it
30
+ * rebuilt into the picture it shows, and the blended draws that land after it cover that picture.
31
+ *
32
+ * **One pipeline does not take part**: the GPU-driven pass blends its own surfaces into a target
33
+ * whose alpha is the mask its blit reads to tell a drawn pixel from an undrawn one, and that blit
34
+ * writes the share back to 1. Its panes still take the occlusion of what they cover. What would
35
+ * close it is a channel for that mask other than alpha.
36
+ *
37
+ * A contributed pass drawing something blended into the scene states the same thing for its own
38
+ * pipeline with `SCENE_ALPHA_COVERS`, `SCENE_ALPHA_TRANSMITS` or `SCENE_ALPHA_KEEPS`.
39
+ */
40
+ /** A blended draw that covers the surface by its alpha: `share * (1 - a)`. */
41
+ export declare const SCENE_ALPHA_COVERS: GPUBlendComponent;
42
+ /** A draw whose alpha is what gets through it, a transmittance or a revealage: `share * a`. */
43
+ export declare const SCENE_ALPHA_TRANSMITS: GPUBlendComponent;
44
+ /** Light added to the surface, or a layer of it: the share unchanged. */
45
+ export declare const SCENE_ALPHA_KEEPS: GPUBlendComponent;
46
+ /**
47
+ * The same three on WebGL2, as the alpha half of `blendFuncSeparate` beside the colour half a
48
+ * caller already had. `src` and `dst` are the colour factors.
49
+ */
50
+ export declare function blendCovering(gl: WebGL2RenderingContext, src: number, dst: number): void;
51
+ /** `SCENE_ALPHA_TRANSMITS` on WebGL2, beside the colour factors `src` and `dst`. */
52
+ export declare function blendTransmitting(gl: WebGL2RenderingContext, src: number, dst: number): void;
53
+ /** `SCENE_ALPHA_KEEPS` on WebGL2, beside the colour factors `src` and `dst`. */
54
+ export declare function blendKeeping(gl: WebGL2RenderingContext, src: number, dst: number): void;