@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
@@ -13,19 +13,30 @@
13
13
  */
14
14
  import type { SurfaceMaterial } from './surfaceTexture.ts';
15
15
 
16
- /** A cutout caster's texture, its cutoff and its UV scale. */
16
+ /** A cutout caster's texture, its cutoff and its UV scale and offset. */
17
17
  export interface CutoutCaster<Texture> {
18
18
  readonly albedo: Texture;
19
19
  readonly cutoff: number;
20
20
  readonly u: number;
21
21
  readonly v: number;
22
+ readonly uOffset: number;
23
+ readonly vOffset: number;
22
24
  }
23
25
 
24
- const answer: { albedo: unknown; cutoff: number; u: number; v: number } = {
26
+ const answer: {
27
+ albedo: unknown;
28
+ cutoff: number;
29
+ u: number;
30
+ v: number;
31
+ uOffset: number;
32
+ vOffset: number;
33
+ } = {
25
34
  albedo: null,
26
35
  cutoff: 0,
27
36
  u: 1,
28
37
  v: 1,
38
+ uOffset: 0,
39
+ vOffset: 0,
29
40
  };
30
41
 
31
42
  /** The cutout `material` casts, or null for an ordinary caster. */
@@ -40,5 +51,7 @@ export function cutoutOf<Texture>(
40
51
  answer.cutoff = cutoff;
41
52
  answer.u = material.uScale ?? 1;
42
53
  answer.v = material.vScale ?? 1;
54
+ answer.uOffset = material.uOffset ?? 0;
55
+ answer.vOffset = material.vOffset ?? 0;
43
56
  return answer as CutoutCaster<Texture>;
44
57
  }
@@ -7,6 +7,7 @@ import { DEPTH_01_TO_CLIP } from './lightVolumeDraw.ts';
7
7
  import { compileProgram, uniformLocations } from './shader.ts';
8
8
  import { DECAL_PROJECT_FRAG } from './shaders/decalProject.ts';
9
9
  import { FULLSCREEN_VERT } from './shaders/fullscreen.ts';
10
+ import { blendKeeping } from './sceneCoverage.ts';
10
11
 
11
12
  /**
12
13
  * Drawn decals on WebGL2: one scissored triangle per mark, multiplied into the finished scene.
@@ -66,7 +67,7 @@ export class DecalPass {
66
67
  gl.depthMask(false);
67
68
  gl.disable(gl.CULL_FACE);
68
69
  gl.enable(gl.BLEND);
69
- gl.blendFunc(gl.ZERO, gl.SRC_COLOR);
70
+ blendKeeping(gl, gl.ZERO, gl.SRC_COLOR);
70
71
  gl.enable(gl.SCISSOR_TEST);
71
72
 
72
73
  /* Unit 0, chosen rather than inherited: `bindTexture` binds to whichever unit is active, and
@@ -7,6 +7,7 @@ import { DEPTH_01_TO_CLIP } from './lightVolumeDraw.ts';
7
7
  import { compileProgram, uniformLocations } from './shader.ts';
8
8
  import { FULLSCREEN_VERT } from './shaders/fullscreen.ts';
9
9
  import { MEDIUM_FRAG, MEDIUM_UPSAMPLE_FRAG } from './shaders/globalMedium.ts';
10
+ import { blendCovering, blendTransmitting } from './sceneCoverage.ts';
10
11
 
11
12
  /** What the march needs about the light, which is the sun and the sky and nothing else. */
12
13
  export interface MediumLight {
@@ -270,7 +271,7 @@ export class GlobalMediumPass {
270
271
  gl.depthMask(false);
271
272
  gl.disable(gl.CULL_FACE);
272
273
  gl.enable(gl.BLEND);
273
- gl.blendFuncSeparate(gl.ONE, gl.SRC_ALPHA, gl.ZERO, gl.ONE);
274
+ blendTransmitting(gl, gl.ONE, gl.SRC_ALPHA);
274
275
 
275
276
  gl.useProgram(this.upsample);
276
277
  gl.bindVertexArray(this.vao);
@@ -293,7 +294,7 @@ export class GlobalMediumPass {
293
294
  gl.activeTexture(gl.TEXTURE0);
294
295
  gl.bindTexture(gl.TEXTURE_2D, null);
295
296
  /* Back to the blend every other pass in this renderer assumes it inherits. */
296
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
297
+ blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
297
298
  gl.disable(gl.BLEND);
298
299
  gl.depthMask(true);
299
300
  gl.enable(gl.DEPTH_TEST);
@@ -2,6 +2,7 @@ import { compileProgram, uniformLocations } from './shader.ts';
2
2
  import { LINE_FRAG, LINE_VERT } from './shaders/line.ts';
3
3
  import { buildSegmentQuads, expandLineSegments } from './segmentQuads.ts';
4
4
  import type { LineSegments } from './linePoints.ts';
5
+ import { blendCovering, blendKeeping } from './sceneCoverage.ts';
5
6
 
6
7
  /**
7
8
  * One draw call for a polyline with a real width — an audio waveform, a trail, a plotted
@@ -102,7 +103,8 @@ export class LineBatch {
102
103
  drawTo(gl: WebGL2RenderingContext, segmentCount: number, additive = false): void {
103
104
  if (segmentCount === 0) return;
104
105
  gl.enable(gl.BLEND);
105
- gl.blendFunc(gl.SRC_ALPHA, additive ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA);
106
+ if (additive) blendKeeping(gl, gl.SRC_ALPHA, gl.ONE);
107
+ else blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
106
108
  gl.depthMask(false);
107
109
  // The quad billboards toward the view direction the same way a bolt's does — see
108
110
  // `line.ts`'s own `cross(dir, view)` — so its winding can flip with the camera exactly
@@ -0,0 +1,43 @@
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
+ const told = new WeakSet<object>();
17
+
18
+ /**
19
+ * Whether a material binds a map that is a picture: albedo, normal or emissive.
20
+ *
21
+ * **Not ORM**, because a one-colour ORM map is how a draw sets a constant roughness and metalness,
22
+ * and read at one texel it is exactly that constant — the materials example draws its spheres so.
23
+ * The three counted here are pictures nobody binds to be read at one point.
24
+ */
25
+ export function materialHasMaps(material: SurfaceMaterial<unknown> | null): boolean {
26
+ if (material === null) return false;
27
+ return (
28
+ (material.albedo ?? null) !== null ||
29
+ (material.normal ?? null) !== null ||
30
+ (material.emissive ?? null) !== null
31
+ );
32
+ }
33
+
34
+ /** Warn the first time `mesh`, which has no coordinates, is drawn with a picture map bound. */
35
+ export function noteMapsWithoutUvs(mesh: object, hasUvs: boolean, materialHasMaps: boolean): void {
36
+ if (hasUvs || !materialHasMaps || told.has(mesh)) return;
37
+ told.add(mesh);
38
+ console.warn(
39
+ '[driftengine] a mesh with no texture coordinates is drawn with an albedo, normal or emissive ' +
40
+ 'map, which it cannot read: every map samples one texel. Give the mesh `uvs` — ' +
41
+ '`MeshBuilder.build({ planarUvs: true })` writes them — or draw it without the maps.',
42
+ );
43
+ }
@@ -136,6 +136,8 @@ export class Mesh {
136
136
  readonly bounds: Bounds = createBounds();
137
137
  /** Whether this mesh carries a rig, which decides which flat program draws it. */
138
138
  readonly isSkinned: boolean;
139
+ /** Whether it has texture coordinates, without which a material's maps read one texel. */
140
+ readonly hasUvs: boolean;
139
141
 
140
142
  /**
141
143
  * This mesh's morph deltas, or null for geometry that does not deform.
@@ -190,6 +192,7 @@ export class Mesh {
190
192
  constructor(gl: WebGL2RenderingContext, data: MeshData, dynamic = false, spread = false) {
191
193
  validateMeshData(data);
192
194
  this.spread = spread;
195
+ this.hasUvs = data.uvs !== undefined;
193
196
  boundsOfPositions(data.positions, this.bounds);
194
197
  this.positionFloats = data.positions.length;
195
198
  const vao = gl.createVertexArray();
@@ -1,6 +1,7 @@
1
1
  import { compileProgram, uniformLocations } from './shader.ts';
2
2
  import { FULLSCREEN_VERT } from './shaders/fullscreen.ts';
3
3
  import { OIT_RESOLVE_FRAG } from './shaders/oitResolve.ts';
4
+ import { blendTransmitting } from './sceneCoverage.ts';
4
5
 
5
6
  /**
6
7
  * Order-independent transparency on WebGL2: two targets, two passes over the same geometry, and a
@@ -179,7 +180,7 @@ export class OitPass {
179
180
  gl.depthMask(false);
180
181
  gl.disable(gl.CULL_FACE);
181
182
  gl.enable(gl.BLEND);
182
- gl.blendFunc(gl.ONE_MINUS_SRC_ALPHA, gl.SRC_ALPHA);
183
+ blendTransmitting(gl, gl.ONE_MINUS_SRC_ALPHA, gl.SRC_ALPHA);
183
184
 
184
185
  gl.useProgram(this.program);
185
186
  gl.activeTexture(gl.TEXTURE0);
@@ -2,6 +2,7 @@ import { compileProgram, uniformLocations } from './shader.ts';
2
2
  import { particleShaders, type ParticleMaterial } from './particleMaterial.ts';
3
3
  import { PARTICLE_BLADES, PARTICLE_INDICES, PARTICLE_VERTS } from './shaders/particle.ts';
4
4
  import type { ParticleInstances } from './particlePool.ts';
5
+ import { blendCovering, blendKeeping } from './sceneCoverage.ts';
5
6
 
6
7
  /**
7
8
  * One draw call for a live particle pool: one camera-facing quad per particle, or a
@@ -239,8 +240,8 @@ export class ParticleBatch {
239
240
  drawTo(gl: WebGL2RenderingContext, count: number): void {
240
241
  if (count === 0) return;
241
242
  gl.enable(gl.BLEND);
242
- if (this.blend === 'additive') gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
243
- else gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
243
+ if (this.blend === 'additive') blendKeeping(gl, gl.SRC_ALPHA, gl.ONE);
244
+ else blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
244
245
  gl.depthMask(false);
245
246
  gl.disable(gl.CULL_FACE);
246
247
 
@@ -21,7 +21,11 @@ import type { Vec3 } from '../math/color.ts';
21
21
  export interface ParticleInstances {
22
22
  /** World position, three floats each. */
23
23
  positions: Float32Array;
24
- /** Metres across, and radians of roll about the particle's own axis. */
24
+ /**
25
+ * Metres from the centre to an edge, a half-width: the quad spans twice this. Then radians of
26
+ * roll about the particle's own axis. This said metres across until 4.8.3, and a caller who took
27
+ * it at its word drew every particle twice as wide as meant.
28
+ */
25
29
  sizes: Float32Array;
26
30
  spins: Float32Array;
27
31
  /** Linear colour, three floats each. */
@@ -72,7 +76,7 @@ export interface ParticlePoolOptions {
72
76
  capacity: number;
73
77
  /** Seconds a particle lives. */
74
78
  lifeSec: number;
75
- /** Size at birth and at death, metres. */
79
+ /** Size at birth and at death: metres from the centre to an edge, so the quad is twice as wide. */
76
80
  sizeStart: number;
77
81
  sizeEnd: number;
78
82
  /** Colour at birth and at death. */
@@ -117,6 +121,15 @@ export class ParticlePool {
117
121
  * together.
118
122
  */
119
123
  private readonly seedOf: Float32Array;
124
+ /**
125
+ * Where each slot's particle sits in the drawn streams, or -1 where it sits nowhere.
126
+ *
127
+ * **What lets `emit` draw a particle the frame it is born.** The streams are compacted, so a
128
+ * slot's entry is wherever `update` last put it; an emit into a slot that has one overwrites
129
+ * that entry, and an emit into one that has none appends. Without it a new particle waited for
130
+ * the next `update` to be drawn at all.
131
+ */
132
+ private readonly drawnAt: Int32Array;
120
133
  private next = 0;
121
134
 
122
135
  constructor(private readonly options: ParticlePoolOptions) {
@@ -138,6 +151,7 @@ export class ParticlePool {
138
151
  this.spin = new Float32Array(capacity);
139
152
  this.scale = new Float32Array(capacity).fill(1);
140
153
  this.seedOf = new Float32Array(capacity);
154
+ this.drawnAt = new Int32Array(capacity).fill(-1);
141
155
  }
142
156
 
143
157
  /** How many particles are alive. */
@@ -184,6 +198,18 @@ export class ParticlePool {
184
198
  this.spin[i] = (hash - Math.floor(hash)) * Math.PI * 2;
185
199
  this.scale[i] = sizeScale;
186
200
  this.seedOf[i] = seed;
201
+ /*
202
+ * Drawn now, at birth, rather than at the next `update`: into the entry this slot already
203
+ * has when it is taking over a particle that was drawn, and onto the end otherwise.
204
+ */
205
+ const held = this.drawnAt[i] as number;
206
+ const at = held >= 0 ? held : this.particles.count;
207
+ this.writeDrawn(i, at, 0, x, y, z, vx, vy, vz);
208
+ if (held < 0) {
209
+ this.drawnAt[i] = at;
210
+ this.instances.count = at + 1;
211
+ this.particles.count = at + 1;
212
+ }
187
213
  }
188
214
 
189
215
  /**
@@ -198,11 +224,15 @@ export class ParticlePool {
198
224
  let count = 0;
199
225
  for (let i = 0; i < this.age.length; i++) {
200
226
  const age = this.age[i] as number;
201
- if (age === Infinity) continue;
227
+ if (age === Infinity) {
228
+ this.drawnAt[i] = -1;
229
+ continue;
230
+ }
202
231
  const life = this.life[i] as number;
203
232
  const next = age + dt;
204
233
  if (next >= life) {
205
234
  this.age[i] = Infinity;
235
+ this.drawnAt[i] = -1;
206
236
  continue;
207
237
  }
208
238
  this.age[i] = next;
@@ -222,68 +252,89 @@ export class ParticlePool {
222
252
  this.y[i] = py;
223
253
  this.z[i] = pz;
224
254
 
225
- const t = next / life;
226
- const size = (o.sizeStart + (o.sizeEnd - o.sizeStart) * t) * (this.scale[i] as number);
227
- const r =
228
- (o.colorStart[0] as number) + ((o.colorEnd[0] as number) - (o.colorStart[0] as number)) * t;
229
- const g =
230
- (o.colorStart[1] as number) + ((o.colorEnd[1] as number) - (o.colorStart[1] as number)) * t;
231
- const b =
232
- (o.colorStart[2] as number) + ((o.colorEnd[2] as number) - (o.colorStart[2] as number)) * t;
233
- /*
234
- * Colour carries the fade rather than an alpha channel: the instanced
235
- * path multiplies a tint into the base mesh and has no per-instance
236
- * opacity, and for an additive puff darkening to nothing *is* fading out.
237
- * It also means the effect needs no blend-state change.
238
- */
239
- const fade = 1 - t * t;
240
- writeInstance(
241
- this.instances,
242
- count,
243
- px,
244
- py,
245
- pz,
246
- size,
247
- this.spin[i] as number,
248
- r * fade,
249
- g * fade,
250
- b * fade,
251
- 0,
252
- 0,
253
- 0,
254
- );
255
- /*
256
- * The same particle for a material that has a real opacity channel. Colour
257
- * is written *unfaded* here, because dimming it is the scatter path's
258
- * workaround and doing both would fade twice — a puff that vanished at half
259
- * its life while still occluding what was behind it.
260
- */
261
- const p = this.particles;
262
- const alphaStart = o.alphaStart ?? 1;
263
- const alphaEnd = o.alphaEnd ?? 0;
264
- p.positions[count * 3] = px;
265
- p.positions[count * 3 + 1] = py;
266
- p.positions[count * 3 + 2] = pz;
267
- p.sizes[count] = size;
268
- p.spins[count] = this.spin[i] as number;
269
- p.colors[count * 3] = r;
270
- p.colors[count * 3 + 1] = g;
271
- p.colors[count * 3 + 2] = b;
272
- p.alphas[count] = alphaStart + (alphaEnd - alphaStart) * t;
273
- p.ages[count] = t;
274
- p.seeds[count] = this.seedOf[i] as number;
275
- p.velocities[count * 3] = vx;
276
- p.velocities[count * 3 + 1] = vy;
277
- p.velocities[count * 3 + 2] = vz;
255
+ this.drawnAt[i] = count;
256
+ this.writeDrawn(i, count, next / life, px, py, pz, vx, vy, vz);
278
257
  count++;
279
258
  }
280
259
  this.instances.count = count;
281
260
  this.particles.count = count;
282
261
  }
283
262
 
263
+ /**
264
+ * One particle's entry in both drawn streams, at `t` of its life: what `update` writes for every
265
+ * live particle and `emit` for a new one, so the two cannot disagree about what a particle looks
266
+ * like.
267
+ */
268
+ private writeDrawn(
269
+ i: number,
270
+ count: number,
271
+ t: number,
272
+ px: number,
273
+ py: number,
274
+ pz: number,
275
+ vx: number,
276
+ vy: number,
277
+ vz: number,
278
+ ): void {
279
+ const o = this.options;
280
+ const size = (o.sizeStart + (o.sizeEnd - o.sizeStart) * t) * (this.scale[i] as number);
281
+ const r =
282
+ (o.colorStart[0] as number) + ((o.colorEnd[0] as number) - (o.colorStart[0] as number)) * t;
283
+ const g =
284
+ (o.colorStart[1] as number) + ((o.colorEnd[1] as number) - (o.colorStart[1] as number)) * t;
285
+ const b =
286
+ (o.colorStart[2] as number) + ((o.colorEnd[2] as number) - (o.colorStart[2] as number)) * t;
287
+ /*
288
+ * Colour carries the fade rather than an alpha channel: the instanced
289
+ * path multiplies a tint into the base mesh and has no per-instance
290
+ * opacity, and for an additive puff darkening to nothing *is* fading out.
291
+ * It also means the effect needs no blend-state change.
292
+ */
293
+ const fade = 1 - t * t;
294
+ writeInstance(
295
+ this.instances,
296
+ count,
297
+ px,
298
+ py,
299
+ pz,
300
+ size,
301
+ this.spin[i] as number,
302
+ r * fade,
303
+ g * fade,
304
+ b * fade,
305
+ 0,
306
+ 0,
307
+ 0,
308
+ );
309
+ /*
310
+ * The same particle for a material that has a real opacity channel. Colour
311
+ * is written *unfaded* here, because dimming it is the scatter path's
312
+ * workaround and doing both would fade twice — a puff that vanished at half
313
+ * its life while still occluding what was behind it.
314
+ */
315
+ const p = this.particles;
316
+ const alphaStart = o.alphaStart ?? 1;
317
+ const alphaEnd = o.alphaEnd ?? 0;
318
+ p.positions[count * 3] = px;
319
+ p.positions[count * 3 + 1] = py;
320
+ p.positions[count * 3 + 2] = pz;
321
+ p.sizes[count] = size;
322
+ p.spins[count] = this.spin[i] as number;
323
+ p.colors[count * 3] = r;
324
+ p.colors[count * 3 + 1] = g;
325
+ p.colors[count * 3 + 2] = b;
326
+ p.alphas[count] = alphaStart + (alphaEnd - alphaStart) * t;
327
+ p.ages[count] = t;
328
+ p.seeds[count] = this.seedOf[i] as number;
329
+ p.velocities[count * 3] = vx;
330
+ p.velocities[count * 3 + 1] = vy;
331
+ p.velocities[count * 3 + 2] = vz;
332
+ }
333
+
284
334
  /** Drop everything, for a respawn or a scene change. */
285
335
  clear(): void {
286
336
  this.age.fill(Infinity);
337
+ this.drawnAt.fill(-1);
287
338
  this.instances.count = 0;
288
339
  this.particles.count = 0;
289
340
  this.next = 0;
@@ -125,6 +125,18 @@ export type PassContext = {
125
125
  readonly pass: GPURenderPassEncoder;
126
126
  /** The frame's jitter, the same array `PrepareContext.jitter` held. */
127
127
  readonly jitter: Float32Array;
128
+ /**
129
+ * The colour format, depth format and sample count of the pass this draw lands in, which a
130
+ * pipeline set on it must match exactly.
131
+ *
132
+ * **Not always the device's.** `PassDevice` says what the frame is; a pass drawn after
133
+ * `endFrame` lands on the canvas, at the canvas's format and one sample, where the frame is
134
+ * the scene target at the scene's samples. A pass that is drawn in both places keeps a
135
+ * pipeline for each and chooses here; one built only from the device is refused by the other.
136
+ */
137
+ readonly format: GPUTextureFormat;
138
+ readonly depthFormat: GPUTextureFormat;
139
+ readonly samples: number;
128
140
  }
129
141
  );
130
142
 
@@ -6,6 +6,7 @@ import { plumeShaders, type PlumeMaterial } from './plumeMaterial.ts';
6
6
  import { buildPlumeGeometry } from './plumeGeometry.ts';
7
7
  import type { Atmosphere } from './atmosphere.ts';
8
8
  import type { Vec3 } from '../math/color.ts';
9
+ import { blendCovering, blendKeeping } from './sceneCoverage.ts';
9
10
 
10
11
  /**
11
12
  * Batched camera-facing quads for volumetric effects — flames, smoke, steam,
@@ -202,8 +203,8 @@ export class PlumeRenderer {
202
203
  bindAtmosphere(gl, u, atmosphere, atmosphereCameraY, underwaterEnabled);
203
204
 
204
205
  gl.enable(gl.BLEND);
205
- if (this.blend === 'additive') gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
206
- else gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
206
+ if (this.blend === 'additive') blendKeeping(gl, gl.SRC_ALPHA, gl.ONE);
207
+ else blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
207
208
  gl.depthMask(false);
208
209
  gl.disable(gl.CULL_FACE);
209
210
 
@@ -20,8 +20,14 @@ import { PREFILTER_SAMPLE_COUNTS } from './prefilterEnvMap.ts';
20
20
  * `aces` adds filmic tone mapping before that, rolling highlights off instead of
21
21
  * clipping them flat. Together they are what three.js does by default, which is why a
22
22
  * world ported from it looks wrong until this exists.
23
+ *
24
+ * `shoulder` keeps sRGB's midtones exactly and eases only the highlights: past 0.8 the brightest
25
+ * channel rolls toward 1 and the other two are scaled with it, so an overbright red spark stays
26
+ * red where `srgb` clips each channel and turns its core pink and wide. It is the curve a ported
27
+ * game's own resolve applied — for a port that has to match one — and it has none of ACES's toe or
28
+ * desaturation. See `HIGHLIGHT_SHOULDER_GLSL` for the arithmetic.
23
29
  */
24
- export type OutputTransform = 'none' | 'srgb' | 'aces';
30
+ export type OutputTransform = 'none' | 'srgb' | 'aces' | 'shoulder';
25
31
 
26
32
  /**
27
33
  * The three settings whose accepted values are a closed set, written as one.
@@ -69,7 +75,8 @@ export interface RenderQuality {
69
75
  * chooses is a look rather than a standard — three.js uses 1/0.6 and says so. This
70
76
  * engine holds no opinion and takes the number from whoever is authoring the world, so
71
77
  * that matching another renderer, or simply preferring a different grade, is a setting
72
- * rather than a patch. Ignored when `outputTransform` is not `aces`.
78
+ * rather than a patch. Scales into `aces` and into `shoulder`, the two curves; ignored by
79
+ * `none` and `srgb`, which have none.
73
80
  */
74
81
  readonly outputExposure: number;
75
82
  /**
@@ -194,12 +201,13 @@ export interface RenderQuality {
194
201
  * existed: a pane casts nothing at all.
195
202
  *
196
203
  * Nothing is allocated until a glass caster first reaches a shadow, so a scene with no glass pays
197
- * no memory and no pass whatever this says. **The lit shader is the exception**: `'full'` and
198
- * `'half'` compile the glass lookup into it, and a lookup that finds no glass and returns at its
199
- * first line still costs the lit pass its registers — measured on the courtyard at night, 0.27 ms
200
- * at 720p on WebGPU, 0.42 ms on WebGL2 and 1.2 ms at 4K. `'off'` compiles none of it, so a world
201
- * that never casts through glass is that much faster for saying so. What would make `'full'` wrong
202
- * is a phone budget with a lot of glass; that is what `'half'` is for. See `glassShadow.ts`.
204
+ * no memory and no pass whatever this says. **Nor any shader, since 4.8.2**: the lit shader is
205
+ * built without the glass lookup and rebuilt with it when the first glass caster reaches a shadow.
206
+ * A lookup that finds no glass still cost the lit pass its registers — measured on the courtyard at
207
+ * 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
208
+ * every scene paid it, glass or none: about 30% of the lit shader's instructions, which on a phone
209
+ * was the lit pass. `'off'` never rebuilds. What would make `'full'` wrong is a phone budget with a
210
+ * lot of glass; that is what `'half'` is for. See `glassShadow.ts`.
203
211
  */
204
212
  readonly glassShadows: GlassShadows;
205
213
  /** Horizontal world-space reach over which a directional shadow dissolves. */
@@ -0,0 +1,78 @@
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
+
41
+ /** A blended draw that covers the surface by its alpha: `share * (1 - a)`. */
42
+ export const SCENE_ALPHA_COVERS: GPUBlendComponent = {
43
+ srcFactor: 'zero',
44
+ dstFactor: 'one-minus-src-alpha',
45
+ operation: 'add',
46
+ };
47
+
48
+ /** A draw whose alpha is what gets through it, a transmittance or a revealage: `share * a`. */
49
+ export const SCENE_ALPHA_TRANSMITS: GPUBlendComponent = {
50
+ srcFactor: 'zero',
51
+ dstFactor: 'src-alpha',
52
+ operation: 'add',
53
+ };
54
+
55
+ /** Light added to the surface, or a layer of it: the share unchanged. */
56
+ export const SCENE_ALPHA_KEEPS: GPUBlendComponent = {
57
+ srcFactor: 'zero',
58
+ dstFactor: 'one',
59
+ operation: 'add',
60
+ };
61
+
62
+ /**
63
+ * The same three on WebGL2, as the alpha half of `blendFuncSeparate` beside the colour half a
64
+ * caller already had. `src` and `dst` are the colour factors.
65
+ */
66
+ export function blendCovering(gl: WebGL2RenderingContext, src: number, dst: number): void {
67
+ gl.blendFuncSeparate(src, dst, gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
68
+ }
69
+
70
+ /** `SCENE_ALPHA_TRANSMITS` on WebGL2, beside the colour factors `src` and `dst`. */
71
+ export function blendTransmitting(gl: WebGL2RenderingContext, src: number, dst: number): void {
72
+ gl.blendFuncSeparate(src, dst, gl.ZERO, gl.SRC_ALPHA);
73
+ }
74
+
75
+ /** `SCENE_ALPHA_KEEPS` on WebGL2, beside the colour factors `src` and `dst`. */
76
+ export function blendKeeping(gl: WebGL2RenderingContext, src: number, dst: number): void {
77
+ gl.blendFuncSeparate(src, dst, gl.ZERO, gl.ONE);
78
+ }
@@ -7,6 +7,7 @@ import { compileProgram, uniformLocations } from './shader.ts';
7
7
  import { SDF_TEXT_FRAG, SDF_TEXT_VERT } from './shaders/sdfText.ts';
8
8
  import type { SurfaceTexture } from './surfaceTexture.ts';
9
9
  import { SDF_TEXT_TEXTURE_UNIT } from './lightBudget.ts';
10
+ import { blendCovering } from './sceneCoverage.ts';
10
11
 
11
12
  /**
12
13
  * Text from a signed distance field, on WebGL2.
@@ -144,7 +145,7 @@ export class SdfTextRenderer {
144
145
  */
145
146
  gl.depthMask(false);
146
147
  gl.enable(gl.BLEND);
147
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
148
+ blendCovering(gl, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
148
149
 
149
150
  this.atlas.bind(gl, SDF_TEXT_TEXTURE_UNIT);
150
151
 
@@ -60,12 +60,13 @@ ${CHANNEL_BEND}
60
60
 
61
61
  #if CUTOUT
62
62
  /*
63
- * **What a cutout caster adds, and where.** The UV at location 5, where every mesh carries it; two
63
+ * **What a cutout caster adds, and where.** The UV at location 5, where every mesh carries it; three
64
64
  * uniforms declared after every other, so no field of the plain variant moves and a binder written
65
65
  * against it stays right for this one; and two outputs after vLightPosition, which keeps location 0.
66
66
  */
67
67
  layout(location = 5) in vec3 aUv;
68
68
  uniform vec2 uUvScale;
69
+ uniform vec2 uUvOffset;
69
70
  uniform float uAlphaCutout;
70
71
  #endif
71
72
 
@@ -104,7 +105,7 @@ void main() {
104
105
  vLightPosition = uLightViewProj * vec4(bent, world.w);
105
106
  gl_Position = vLightPosition;
106
107
  #if CUTOUT
107
- vUv = vec3(aUv.xy * uUvScale, aUv.z);
108
+ vUv = vec3(aUv.xy * uUvScale + uUvOffset, aUv.z);
108
109
  vAlphaCutout = uAlphaCutout;
109
110
  #endif
110
111
  #if GLASS