topazcube 0.1.31 → 0.1.35

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 (103) hide show
  1. package/LICENSE.txt +0 -0
  2. package/README.md +0 -0
  3. package/dist/Renderer.cjs +20844 -0
  4. package/dist/Renderer.cjs.map +1 -0
  5. package/dist/Renderer.js +20827 -0
  6. package/dist/Renderer.js.map +1 -0
  7. package/dist/client.cjs +91 -260
  8. package/dist/client.cjs.map +1 -1
  9. package/dist/client.js +68 -215
  10. package/dist/client.js.map +1 -1
  11. package/dist/server.cjs +165 -432
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +117 -370
  14. package/dist/server.js.map +1 -1
  15. package/dist/terminal.cjs +113 -200
  16. package/dist/terminal.cjs.map +1 -1
  17. package/dist/terminal.js +50 -51
  18. package/dist/terminal.js.map +1 -1
  19. package/dist/utils-CRhi1BDa.cjs +259 -0
  20. package/dist/utils-CRhi1BDa.cjs.map +1 -0
  21. package/dist/utils-D7tXt6-2.js +260 -0
  22. package/dist/utils-D7tXt6-2.js.map +1 -0
  23. package/package.json +19 -15
  24. package/src/{client.ts → network/client.js} +170 -403
  25. package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
  26. package/src/{compress-node.ts → network/compress-node.js} +8 -14
  27. package/src/{server.ts → network/server.js} +229 -317
  28. package/src/{terminal.js → network/terminal.js} +0 -0
  29. package/src/{topazcube.ts → network/topazcube.js} +2 -2
  30. package/src/network/utils.js +375 -0
  31. package/src/renderer/Camera.js +191 -0
  32. package/src/renderer/DebugUI.js +703 -0
  33. package/src/renderer/Geometry.js +1049 -0
  34. package/src/renderer/Material.js +64 -0
  35. package/src/renderer/Mesh.js +211 -0
  36. package/src/renderer/Node.js +112 -0
  37. package/src/renderer/Pipeline.js +645 -0
  38. package/src/renderer/Renderer.js +1496 -0
  39. package/src/renderer/Skin.js +792 -0
  40. package/src/renderer/Texture.js +584 -0
  41. package/src/renderer/core/AssetManager.js +394 -0
  42. package/src/renderer/core/CullingSystem.js +308 -0
  43. package/src/renderer/core/EntityManager.js +541 -0
  44. package/src/renderer/core/InstanceManager.js +343 -0
  45. package/src/renderer/core/ParticleEmitter.js +358 -0
  46. package/src/renderer/core/ParticleSystem.js +564 -0
  47. package/src/renderer/core/SpriteSystem.js +349 -0
  48. package/src/renderer/gltf.js +563 -0
  49. package/src/renderer/math.js +161 -0
  50. package/src/renderer/rendering/HistoryBufferManager.js +333 -0
  51. package/src/renderer/rendering/ProbeCapture.js +1495 -0
  52. package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
  53. package/src/renderer/rendering/RenderGraph.js +2258 -0
  54. package/src/renderer/rendering/passes/AOPass.js +308 -0
  55. package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
  56. package/src/renderer/rendering/passes/BasePass.js +101 -0
  57. package/src/renderer/rendering/passes/BloomPass.js +420 -0
  58. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  59. package/src/renderer/rendering/passes/FogPass.js +445 -0
  60. package/src/renderer/rendering/passes/GBufferPass.js +730 -0
  61. package/src/renderer/rendering/passes/HiZPass.js +744 -0
  62. package/src/renderer/rendering/passes/LightingPass.js +753 -0
  63. package/src/renderer/rendering/passes/ParticlePass.js +841 -0
  64. package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
  65. package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
  66. package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
  67. package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
  68. package/src/renderer/rendering/passes/SSGIPass.js +266 -0
  69. package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
  70. package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
  71. package/src/renderer/rendering/passes/TransparentPass.js +831 -0
  72. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  73. package/src/renderer/rendering/shaders/ao.wgsl +182 -0
  74. package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
  75. package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
  76. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  77. package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
  78. package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
  79. package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
  80. package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
  81. package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
  82. package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
  83. package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
  84. package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
  85. package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
  86. package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
  87. package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
  88. package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
  89. package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
  90. package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
  91. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  92. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  93. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  94. package/src/renderer/utils/BoundingSphere.js +439 -0
  95. package/src/renderer/utils/Frustum.js +281 -0
  96. package/src/renderer/utils/Raycaster.js +761 -0
  97. package/dist/client.d.cts +0 -211
  98. package/dist/client.d.ts +0 -211
  99. package/dist/server.d.cts +0 -120
  100. package/dist/server.d.ts +0 -120
  101. package/dist/terminal.d.cts +0 -64
  102. package/dist/terminal.d.ts +0 -64
  103. package/src/utils.ts +0 -403
@@ -0,0 +1,440 @@
1
+ // Particle simulation compute shader
2
+ // Updates particle positions, velocities, lifetimes, and handles spawning
3
+ // Also calculates per-particle lighting with temporal smoothing
4
+
5
+ const WORKGROUP_SIZE: u32 = 64u;
6
+ const MAX_EMITTERS: u32 = 16u;
7
+ const CASCADE_COUNT = 3;
8
+ const MAX_LIGHTS = 64u;
9
+ const MAX_SPOT_SHADOWS = 8;
10
+ const LIGHTING_FADE_TIME: f32 = 0.3; // Seconds to smooth lighting changes
11
+
12
+ // Spot shadow atlas constants
13
+ const SPOT_ATLAS_WIDTH: f32 = 2048.0;
14
+ const SPOT_ATLAS_HEIGHT: f32 = 2048.0;
15
+ const SPOT_TILE_SIZE: f32 = 512.0;
16
+ const SPOT_TILES_PER_ROW: i32 = 4;
17
+
18
+ // Particle data structure (80 bytes, matches JS PARTICLE_STRIDE)
19
+ // flags: bit 0 = alive, bit 1 = additive, bits 8-15 = emitter index
20
+ struct Particle {
21
+ position: vec3f, // World position
22
+ lifetime: f32, // Remaining life (seconds), <=0 = dead
23
+ velocity: vec3f, // Movement per second
24
+ maxLifetime: f32, // Initial lifetime for fade calculation
25
+ color: vec4f, // RGBA with computed alpha
26
+ size: vec2f, // Current width/height
27
+ rotation: f32, // Current rotation in radians
28
+ flags: u32, // Bit 0 = alive, bit 1 = additive, bits 8-15 = emitter index
29
+ lighting: vec3f, // Pre-computed lighting (smoothed over time)
30
+ lightingPad: f32, // Padding for alignment
31
+ }
32
+
33
+ // Light structure (must match LightingPass)
34
+ struct Light {
35
+ enabled: u32,
36
+ position: vec3f,
37
+ color: vec4f,
38
+ direction: vec3f,
39
+ geom: vec4f, // x = radius, y = inner cone, z = outer cone, w = distance fade
40
+ shadowIndex: i32,
41
+ }
42
+
43
+ struct CascadeMatrices {
44
+ matrices: array<mat4x4<f32>, CASCADE_COUNT>,
45
+ }
46
+
47
+ struct SpotShadowMatrices {
48
+ matrices: array<mat4x4<f32>, MAX_SPOT_SHADOWS>,
49
+ }
50
+
51
+ // Spawn request structure (64 bytes, matches JS SPAWN_REQUEST_STRIDE)
52
+ struct SpawnRequest {
53
+ position: vec3f,
54
+ lifetime: f32,
55
+ velocity: vec3f,
56
+ maxLifetime: f32,
57
+ color: vec4f,
58
+ startSize: f32,
59
+ endSize: f32,
60
+ rotation: f32, // Initial rotation (random)
61
+ flags: u32, // Bit 0 = alive, bit 1 = additive, bits 8-15 = emitter index
62
+ }
63
+
64
+ // Per-emitter simulation settings (64 bytes each)
65
+ struct EmitterSettings {
66
+ gravity: vec3f,
67
+ drag: f32,
68
+ turbulence: f32,
69
+ fadeIn: f32,
70
+ fadeOut: f32,
71
+ rotationSpeed: f32,
72
+ startSize: f32,
73
+ endSize: f32,
74
+ baseAlpha: f32,
75
+ padding: f32,
76
+ }
77
+
78
+ struct SimulationUniforms {
79
+ dt: f32,
80
+ time: f32,
81
+ maxParticles: u32,
82
+ emitterCount: u32,
83
+ // Lighting parameters
84
+ cameraPosition: vec3f,
85
+ shadowBias: f32,
86
+ lightDir: vec3f,
87
+ shadowStrength: f32,
88
+ lightColor: vec4f,
89
+ ambientColor: vec4f,
90
+ cascadeSizes: vec4f,
91
+ lightCount: u32,
92
+ pad1: u32,
93
+ pad2: u32,
94
+ pad3: u32,
95
+ }
96
+
97
+ struct Counters {
98
+ aliveCount: atomic<u32>,
99
+ nextFreeIndex: atomic<u32>,
100
+ spawnCount: u32,
101
+ frameCount: u32,
102
+ }
103
+
104
+ // Core bindings
105
+ @group(0) @binding(0) var<uniform> uniforms: SimulationUniforms;
106
+ @group(0) @binding(1) var<storage, read_write> particles: array<Particle>;
107
+ @group(0) @binding(2) var<storage, read_write> counters: Counters;
108
+ @group(0) @binding(3) var<storage, read> spawnRequests: array<SpawnRequest>;
109
+ @group(0) @binding(4) var<storage, read> emitterSettings: array<EmitterSettings>;
110
+
111
+ // Lighting bindings
112
+ @group(0) @binding(5) var<storage, read> emitterRenderSettings: array<EmitterRenderSettings>;
113
+ @group(0) @binding(6) var shadowMapArray: texture_depth_2d_array;
114
+ @group(0) @binding(7) var shadowSampler: sampler_comparison;
115
+ @group(0) @binding(8) var<storage, read> cascadeMatrices: CascadeMatrices;
116
+ @group(0) @binding(9) var<storage, read> lights: array<Light, MAX_LIGHTS>;
117
+ @group(0) @binding(10) var spotShadowAtlas: texture_depth_2d;
118
+ @group(0) @binding(11) var<storage, read> spotMatrices: SpotShadowMatrices;
119
+
120
+ // Emitter render settings (lit, emissive)
121
+ struct EmitterRenderSettings {
122
+ lit: f32,
123
+ emissive: f32,
124
+ softness: f32,
125
+ zOffset: f32,
126
+ }
127
+
128
+ // Simple noise function for turbulence
129
+ fn hash(p: vec3f) -> f32 {
130
+ var p3 = fract(p * 0.1031);
131
+ p3 += dot(p3, p3.yzx + 33.33);
132
+ return fract((p3.x + p3.y) * p3.z);
133
+ }
134
+
135
+ fn noise3D(p: vec3f) -> vec3f {
136
+ let i = floor(p);
137
+ let f = fract(p);
138
+ let u = f * f * (3.0 - 2.0 * f);
139
+
140
+ return vec3f(
141
+ mix(hash(i), hash(i + vec3f(1.0, 0.0, 0.0)), u.x),
142
+ mix(hash(i + vec3f(0.0, 1.0, 0.0)), hash(i + vec3f(1.0, 1.0, 0.0)), u.x),
143
+ mix(hash(i + vec3f(0.0, 0.0, 1.0)), hash(i + vec3f(1.0, 0.0, 1.0)), u.x)
144
+ ) * 2.0 - 1.0;
145
+ }
146
+
147
+ // ============= LIGHTING FUNCTIONS =============
148
+
149
+ // Squircle distance for cascade selection
150
+ fn squircleDistanceXZ(offset: vec2f, size: f32) -> f32 {
151
+ let normalized = offset / size;
152
+ let absNorm = abs(normalized);
153
+ return pow(pow(absNorm.x, 4.0) + pow(absNorm.y, 4.0), 0.25);
154
+ }
155
+
156
+ // Sample shadow from cascade (single tap for compute shader performance)
157
+ fn sampleCascadeShadow(worldPos: vec3f, cascadeIndex: i32) -> f32 {
158
+ let bias = uniforms.shadowBias;
159
+ let lightMatrix = cascadeMatrices.matrices[cascadeIndex];
160
+ let lightSpacePos = lightMatrix * vec4f(worldPos, 1.0);
161
+ let projCoords = lightSpacePos.xyz / lightSpacePos.w;
162
+ let shadowUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);
163
+ let currentDepth = projCoords.z - bias;
164
+
165
+ // Check bounds
166
+ if (shadowUV.x < 0.0 || shadowUV.x > 1.0 || shadowUV.y < 0.0 || shadowUV.y > 1.0 ||
167
+ currentDepth < 0.0 || currentDepth > 1.0) {
168
+ return 1.0;
169
+ }
170
+
171
+ // Single tap shadow for compute shader (faster)
172
+ return textureSampleCompareLevel(shadowMapArray, shadowSampler, shadowUV, cascadeIndex, currentDepth);
173
+ }
174
+
175
+ // Calculate cascade shadow for particle
176
+ fn calculateParticleShadow(worldPos: vec3f) -> f32 {
177
+ let camXZ = vec2f(uniforms.cameraPosition.x, uniforms.cameraPosition.z);
178
+ let posXZ = vec2f(worldPos.x, worldPos.z);
179
+ let offsetXZ = posXZ - camXZ;
180
+
181
+ let dist0 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.x);
182
+ let dist1 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.y);
183
+ let dist2 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.z);
184
+
185
+ var shadow = 1.0;
186
+ if (dist0 < 0.95) {
187
+ shadow = sampleCascadeShadow(worldPos, 0);
188
+ } else if (dist1 < 0.95) {
189
+ shadow = sampleCascadeShadow(worldPos, 1);
190
+ } else if (dist2 < 0.95) {
191
+ shadow = sampleCascadeShadow(worldPos, 2);
192
+ }
193
+
194
+ return mix(1.0 - uniforms.shadowStrength, 1.0, shadow);
195
+ }
196
+
197
+ // Calculate spot shadow (single tap)
198
+ fn calculateSpotShadow(worldPos: vec3f, slotIndex: i32) -> f32 {
199
+ if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {
200
+ return 1.0;
201
+ }
202
+
203
+ let lightMatrix = spotMatrices.matrices[slotIndex];
204
+ let lightSpacePos = lightMatrix * vec4f(worldPos, 1.0);
205
+ let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);
206
+ let projCoords = lightSpacePos.xyz / w;
207
+
208
+ if (projCoords.z < 0.0 || projCoords.z > 1.0 || abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0) {
209
+ return 1.0;
210
+ }
211
+
212
+ let col = slotIndex % SPOT_TILES_PER_ROW;
213
+ let row = slotIndex / SPOT_TILES_PER_ROW;
214
+ let localUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);
215
+ let tileOffset = vec2f(f32(col), f32(row)) * SPOT_TILE_SIZE;
216
+ let atlasUV = (tileOffset + localUV * SPOT_TILE_SIZE) / vec2f(SPOT_ATLAS_WIDTH, SPOT_ATLAS_HEIGHT);
217
+ let currentDepth = clamp(projCoords.z - uniforms.shadowBias * 3.0, 0.001, 0.999);
218
+
219
+ return textureSampleCompareLevel(spotShadowAtlas, shadowSampler, atlasUV, currentDepth);
220
+ }
221
+
222
+ // Calculate full lighting for a particle at given position
223
+ fn calculateParticleLighting(worldPos: vec3f, emitterIdx: u32) -> vec3f {
224
+ let renderSettings = emitterRenderSettings[min(emitterIdx, MAX_EMITTERS - 1u)];
225
+
226
+ // Unlit particles - just return emissive multiplier as 1.0
227
+ if (renderSettings.lit < 0.5) {
228
+ return vec3f(renderSettings.emissive);
229
+ }
230
+
231
+ // Particle normal is always up
232
+ let normal = vec3f(0.0, 1.0, 0.0);
233
+
234
+ // Start with ambient
235
+ var lighting = uniforms.ambientColor.rgb * uniforms.ambientColor.a;
236
+
237
+ // Main directional light with shadow
238
+ let shadow = calculateParticleShadow(worldPos);
239
+ let NdotL = max(dot(normal, uniforms.lightDir), 0.0);
240
+ lighting += uniforms.lightColor.rgb * uniforms.lightColor.a * NdotL * shadow;
241
+
242
+ // Point and spot lights
243
+ let lightCount = uniforms.lightCount;
244
+ for (var i = 0u; i < min(lightCount, MAX_LIGHTS); i++) {
245
+ let light = lights[i];
246
+ if (light.enabled == 0u) { continue; }
247
+
248
+ let lightVec = light.position - worldPos;
249
+ let dist = length(lightVec);
250
+ let lightDir = normalize(lightVec);
251
+ let distanceFade = light.geom.w;
252
+
253
+ // Attenuation
254
+ let radius = max(light.geom.x, 0.001);
255
+ let attenuation = max(0.0, 1.0 - dist / radius);
256
+ let attenuationSq = attenuation * attenuation * distanceFade;
257
+ if (attenuationSq <= 0.001) { continue; }
258
+
259
+ // Spot cone
260
+ let innerCone = light.geom.y;
261
+ let outerCone = light.geom.z;
262
+ var spotAttenuation = 1.0;
263
+ let isSpotlight = outerCone > 0.0;
264
+ if (isSpotlight) {
265
+ let spotCos = dot(-lightDir, normalize(light.direction));
266
+ spotAttenuation = smoothstep(outerCone, innerCone, spotCos);
267
+ }
268
+ if (spotAttenuation <= 0.001) { continue; }
269
+
270
+ // Spot shadow
271
+ var lightShadow = 1.0;
272
+ if (isSpotlight && light.shadowIndex >= 0) {
273
+ lightShadow = calculateSpotShadow(worldPos, light.shadowIndex);
274
+ }
275
+
276
+ // Add contribution
277
+ let pNdotL = max(dot(normal, lightDir), 0.0);
278
+ let pIntensity = light.color.a * attenuationSq * spotAttenuation * lightShadow * pNdotL;
279
+ lighting += pIntensity * light.color.rgb;
280
+ }
281
+
282
+ return lighting * renderSettings.emissive;
283
+ }
284
+
285
+ // Spawn pass: initialize new particles from spawn requests
286
+ @compute @workgroup_size(WORKGROUP_SIZE)
287
+ fn spawn(@builtin(global_invocation_id) globalId: vec3u) {
288
+ let idx = globalId.x;
289
+
290
+ // Check if this thread should process a spawn request
291
+ if (idx >= counters.spawnCount) {
292
+ return;
293
+ }
294
+
295
+ // Find a free particle slot (try multiple slots if needed)
296
+ let maxAttempts = 8u; // Limit search to avoid infinite loop
297
+ var particleIdx = 0u;
298
+ var foundSlot = false;
299
+
300
+ for (var attempt = 0u; attempt < maxAttempts; attempt++) {
301
+ let rawIdx = atomicAdd(&counters.nextFreeIndex, 1u);
302
+ particleIdx = rawIdx % uniforms.maxParticles;
303
+
304
+ // Check if this slot is free (particle is dead)
305
+ let existing = particles[particleIdx];
306
+ if ((existing.flags & 1u) == 0u || existing.lifetime <= 0.0) {
307
+ foundSlot = true;
308
+ break;
309
+ }
310
+ }
311
+
312
+ // If no free slot found, skip this spawn
313
+ if (!foundSlot) {
314
+ return;
315
+ }
316
+
317
+ // Read spawn request
318
+ let req = spawnRequests[idx];
319
+
320
+ // Initialize particle from spawn request
321
+ var p: Particle;
322
+ p.position = req.position;
323
+ p.lifetime = req.lifetime;
324
+ p.velocity = req.velocity;
325
+ p.maxLifetime = req.maxLifetime;
326
+ p.color = req.color;
327
+ p.size = vec2f(req.startSize, req.startSize);
328
+ p.rotation = req.rotation; // Random initial rotation
329
+ p.flags = req.flags;
330
+ // Initialize lighting to ambient (will smooth towards actual lighting)
331
+ p.lighting = uniforms.ambientColor.rgb * uniforms.ambientColor.a;
332
+ p.lightingPad = 0.0;
333
+
334
+ // Store particle
335
+ particles[particleIdx] = p;
336
+
337
+ // Increment alive count
338
+ atomicAdd(&counters.aliveCount, 1u);
339
+ }
340
+
341
+ // Simulate pass: update existing particles
342
+ @compute @workgroup_size(WORKGROUP_SIZE)
343
+ fn simulate(@builtin(global_invocation_id) globalId: vec3u) {
344
+ let idx = globalId.x;
345
+
346
+ // Bounds check
347
+ if (idx >= uniforms.maxParticles) {
348
+ return;
349
+ }
350
+
351
+ var p = particles[idx];
352
+
353
+ // Skip dead particles
354
+ if ((p.flags & 1u) == 0u || p.lifetime <= 0.0) {
355
+ return;
356
+ }
357
+
358
+ // Get emitter index from flags (bits 8-15)
359
+ let emitterIdx = (p.flags >> 8u) & 0xFFu;
360
+ let settings = emitterSettings[min(emitterIdx, MAX_EMITTERS - 1u)];
361
+
362
+ let dt = uniforms.dt;
363
+
364
+ // Update lifetime
365
+ p.lifetime -= dt;
366
+
367
+ // Check if particle died
368
+ if (p.lifetime <= 0.0) {
369
+ p.flags = 0u; // Mark as dead
370
+ p.lifetime = 0.0;
371
+ atomicSub(&counters.aliveCount, 1u);
372
+ particles[idx] = p;
373
+ return;
374
+ }
375
+
376
+ // Calculate life progress (0 = just born, 1 = about to die)
377
+ let lifeProgress = 1.0 - (p.lifetime / p.maxLifetime);
378
+
379
+ // Apply gravity (per-emitter)
380
+ p.velocity += settings.gravity * dt;
381
+
382
+ // Apply drag (per-emitter)
383
+ let dragFactor = 1.0 - settings.drag * dt;
384
+ p.velocity *= max(dragFactor, 0.0);
385
+
386
+ // Apply turbulence (per-emitter)
387
+ if (settings.turbulence > 0.0) {
388
+ let noisePos = p.position * 0.5 + vec3f(uniforms.time * 0.1, 0.0, 0.0);
389
+ let turbulenceForce = noise3D(noisePos + vec3f(p.rotation * 10.0)) * settings.turbulence;
390
+ p.velocity += turbulenceForce * dt * 10.0;
391
+ }
392
+
393
+ // Update position
394
+ p.position += p.velocity * dt;
395
+
396
+ // Interpolate size over lifetime (per-emitter)
397
+ let currentSize = mix(settings.startSize, settings.endSize, lifeProgress);
398
+ p.size = vec2f(currentSize, currentSize);
399
+
400
+ // Update rotation (per-emitter)
401
+ let rotationDir = select(-1.0, 1.0, p.rotation >= 0.0);
402
+ p.rotation += rotationDir * settings.rotationSpeed * dt;
403
+
404
+ // Calculate alpha with fade in/out (per-emitter)
405
+ var alpha = settings.baseAlpha;
406
+
407
+ // Fade in
408
+ let fadeInDuration = settings.fadeIn / p.maxLifetime;
409
+ if (lifeProgress < fadeInDuration && fadeInDuration > 0.0) {
410
+ alpha *= lifeProgress / fadeInDuration;
411
+ }
412
+
413
+ // Fade out
414
+ let fadeOutStart = 1.0 - (settings.fadeOut / p.maxLifetime);
415
+ if (lifeProgress > fadeOutStart && fadeOutStart < 1.0) {
416
+ let fadeOutProgress = (lifeProgress - fadeOutStart) / (1.0 - fadeOutStart);
417
+ alpha *= 1.0 - fadeOutProgress;
418
+ }
419
+
420
+ // Only update alpha, preserve RGB from spawn
421
+ p.color.a = alpha;
422
+
423
+ // Calculate target lighting at particle center
424
+ let targetLighting = calculateParticleLighting(p.position, emitterIdx);
425
+
426
+ // Smooth lighting over time (exponential moving average)
427
+ // lerpFactor = 1 - exp(-dt / fadeTime) ≈ dt / fadeTime for small dt
428
+ let lerpFactor = clamp(dt / LIGHTING_FADE_TIME, 0.0, 1.0);
429
+ p.lighting = mix(p.lighting, targetLighting, lerpFactor);
430
+
431
+ // Write updated particle
432
+ particles[idx] = p;
433
+ }
434
+
435
+ // Reset counters (run once per frame before spawn)
436
+ @compute @workgroup_size(1)
437
+ fn resetCounters() {
438
+ // Reset spawn-related counter but preserve alive count
439
+ atomicStore(&counters.nextFreeIndex, 0u);
440
+ }