reze-engine 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/particles.js DELETED
@@ -1,183 +0,0 @@
1
- // One-shot GPU particle emissions (model dissolve/materialize, hit sparks…).
2
- // Stateless by construction: emit() uploads static per-particle attributes
3
- // once, the vertex shader evaluates each particle's whole flight from the
4
- // elapsed time, and the emission frees itself when it expires. Zero per-frame
5
- // CPU work beyond a single time-uniform write shared by every emission.
6
- import { Vec3 } from "./math";
7
- import { PARTICLES_SHADER_WGSL } from "./shaders/passes/particles";
8
- /** Floats per particle: point.xyz, scatter.xyz, rand(delay, lifeScale, seed, sizeScale). */
9
- const PARTICLE_STRIDE = 10;
10
- export class ParticleSystem {
11
- constructor(device, cameraBuffer, formats) {
12
- this.timeData = new Float32Array(1);
13
- this.emissions = [];
14
- this.device = device;
15
- this.cameraBuffer = cameraBuffer;
16
- this.timeBuffer = device.createBuffer({
17
- label: "particle time",
18
- size: 16,
19
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
20
- });
21
- this.bindGroupLayout = device.createBindGroupLayout({
22
- label: "particle bind group layout",
23
- entries: [
24
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
25
- { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
26
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
27
- ],
28
- });
29
- const module = device.createShaderModule({ label: "particles", code: PARTICLES_SHADER_WGSL });
30
- const additive = {
31
- color: { srcFactor: "one", dstFactor: "one" },
32
- alpha: { srcFactor: "one", dstFactor: "one" },
33
- };
34
- this.pipeline = device.createRenderPipeline({
35
- label: "particle pipeline",
36
- layout: device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
37
- vertex: {
38
- module,
39
- entryPoint: "vs_main",
40
- buffers: [
41
- {
42
- arrayStride: PARTICLE_STRIDE * 4,
43
- stepMode: "instance",
44
- attributes: [
45
- { shaderLocation: 0, offset: 0, format: "float32x3" },
46
- { shaderLocation: 1, offset: 12, format: "float32x3" },
47
- { shaderLocation: 2, offset: 24, format: "float32x4" },
48
- ],
49
- },
50
- ],
51
- },
52
- fragment: {
53
- module,
54
- entryPoint: "fs_main",
55
- targets: [
56
- { format: formats.hdr, blend: additive },
57
- { format: formats.mask, blend: additive },
58
- ],
59
- },
60
- primitive: { topology: "triangle-list" },
61
- // Occluded by the scene, never occluding: glitter reads as light, and
62
- // light writing depth would punch holes into the transparent phase.
63
- depthStencil: { format: formats.depth, depthWriteEnabled: false, depthCompare: "less-equal" },
64
- multisample: { count: formats.sampleCount },
65
- });
66
- }
67
- /** Upload one emission. `now` is the shared clock (seconds) draw() receives. */
68
- emit(opts, now) {
69
- const count = Math.floor(opts.points.length / 3);
70
- if (count === 0)
71
- return;
72
- const mode = opts.mode === "converge" ? 1 : 0;
73
- const duration = opts.duration ?? 1.6;
74
- const color = opts.color ?? new Vec3(4.0, 3.0, 1.6);
75
- const size = opts.size ?? 0.5;
76
- const scatter = opts.scatter ?? 14;
77
- const lift = opts.lift ?? 6;
78
- const swirl = opts.swirl ?? 2;
79
- // Delays stagger the front so the cloud dissolves as a sweep, and each
80
- // particle's life fits delay + flight inside the emission's duration.
81
- const data = new Float32Array(count * PARTICLE_STRIDE);
82
- let cx = 0;
83
- let cy = 0;
84
- let cz = 0;
85
- for (let i = 0; i < count; i++) {
86
- const o = i * PARTICLE_STRIDE;
87
- const px = opts.points[i * 3];
88
- const py = opts.points[i * 3 + 1];
89
- const pz = opts.points[i * 3 + 2];
90
- cx += px;
91
- cy += py;
92
- cz += pz;
93
- data[o] = px;
94
- data[o + 1] = py;
95
- data[o + 2] = pz;
96
- // Random direction on the sphere, mildly flattened so the cloud spreads
97
- // outward more than it dives into the floor.
98
- const theta = Math.random() * Math.PI * 2;
99
- const zr = Math.random() * 2 - 1;
100
- const r = Math.sqrt(Math.max(0, 1 - zr * zr));
101
- const dist = scatter * (0.35 + 0.65 * Math.random());
102
- data[o + 3] = Math.cos(theta) * r * dist;
103
- data[o + 4] = zr * 0.5 * dist;
104
- data[o + 5] = Math.sin(theta) * r * dist;
105
- const delay = Math.random() * duration * 0.3;
106
- data[o + 6] = delay;
107
- data[o + 7] = (duration - delay) / duration; // lifeScale: everyone lands inside duration
108
- data[o + 8] = Math.random();
109
- data[o + 9] = 0.5 + Math.random();
110
- }
111
- const particleBuffer = this.device.createBuffer({
112
- label: "particle emission",
113
- size: data.byteLength,
114
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
115
- });
116
- this.device.queue.writeBuffer(particleBuffer, 0, data);
117
- const uni = new Float32Array(12);
118
- uni[0] = color.x;
119
- uni[1] = color.y;
120
- uni[2] = color.z;
121
- uni[3] = size;
122
- uni[4] = cx / count;
123
- uni[5] = cy / count;
124
- uni[6] = cz / count;
125
- uni[7] = mode;
126
- uni[8] = now;
127
- uni[9] = duration;
128
- uni[10] = lift;
129
- uni[11] = swirl;
130
- const uniformBuffer = this.device.createBuffer({
131
- label: "particle emission uniforms",
132
- size: uni.byteLength,
133
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
134
- });
135
- this.device.queue.writeBuffer(uniformBuffer, 0, uni);
136
- const bindGroup = this.device.createBindGroup({
137
- label: "particle emission bind group",
138
- layout: this.bindGroupLayout,
139
- entries: [
140
- { binding: 0, resource: { buffer: this.cameraBuffer } },
141
- { binding: 1, resource: { buffer: uniformBuffer } },
142
- { binding: 2, resource: { buffer: this.timeBuffer } },
143
- ],
144
- });
145
- this.emissions.push({ particleBuffer, uniformBuffer, bindGroup, count, endTime: now + duration + 0.1 });
146
- }
147
- get active() {
148
- return this.emissions.length > 0;
149
- }
150
- /** Record draws into the main pass (after the transparent phase). */
151
- draw(pass, now) {
152
- if (this.emissions.length === 0)
153
- return;
154
- const live = [];
155
- let bound = false;
156
- for (const e of this.emissions) {
157
- if (now >= e.endTime) {
158
- e.particleBuffer.destroy();
159
- e.uniformBuffer.destroy();
160
- continue;
161
- }
162
- if (!bound) {
163
- this.timeData[0] = now;
164
- this.device.queue.writeBuffer(this.timeBuffer, 0, this.timeData);
165
- pass.setPipeline(this.pipeline);
166
- bound = true;
167
- }
168
- pass.setBindGroup(0, e.bindGroup);
169
- pass.setVertexBuffer(0, e.particleBuffer);
170
- pass.draw(6, e.count);
171
- live.push(e);
172
- }
173
- this.emissions = live;
174
- }
175
- dispose() {
176
- for (const e of this.emissions) {
177
- e.particleBuffer.destroy();
178
- e.uniformBuffer.destroy();
179
- }
180
- this.emissions = [];
181
- this.timeBuffer.destroy();
182
- }
183
- }
@@ -1,2 +0,0 @@
1
- export declare const PARTICLES_SHADER_WGSL = "\nstruct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };\nstruct EmissionUniforms {\n color: vec3f, size: f32,\n centroidPos: vec3f, mode: f32, // 0 = burst (point -> scatter), 1 = converge (scatter -> point)\n startTime: f32, duration: f32, lift: f32, swirl: f32,\n};\nstruct TimeUniforms { now: f32, };\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> emission: EmissionUniforms;\n@group(0) @binding(2) var<uniform> timeU: TimeUniforms;\n\nstruct VSOut {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n @location(1) fade: f32,\n};\n\nfn easeOutCubic(t: f32) -> f32 { let u = 1.0 - t; return 1.0 - u * u * u; }\nfn easeInOut(t: f32) -> f32 { return t * t * (3.0 - 2.0 * t); }\n\n@vertex\nfn vs_main(\n @builtin(vertex_index) vi: u32,\n @location(0) point: vec3f, // mesh anchor (spawn for burst, target for converge)\n @location(1) scatter: vec3f, // pre-scaled scatter offset at the far end\n @location(2) rand: vec4f, // delay, lifeScale, seed, sizeScale\n) -> VSOut {\n var o: VSOut;\n let elapsed = timeU.now - emission.startTime;\n let life = max(emission.duration * rand.y, 1e-3);\n let t = clamp((elapsed - rand.x) / life, 0.0, 1.0);\n\n // Flight progress k runs point->scatter for burst, the reverse for converge;\n // the whole cloud winds around its centroid as it goes.\n var k: f32;\n if (emission.mode < 0.5) { k = easeOutCubic(t); } else { k = 1.0 - easeInOut(t); }\n let base = point + scatter * k + vec3f(0.0, emission.lift * k, 0.0);\n let rel = base - emission.centroidPos;\n let a = emission.swirl * k;\n let c = cos(a);\n let s = sin(a);\n let world0 = emission.centroidPos + vec3f(rel.x * c + rel.z * s, rel.y, -rel.x * s + rel.z * c);\n\n // Burst dies as it scatters; converge lights up fast and vanishes on arrival\n // (the reveal underneath takes over).\n var alpha: f32;\n if (emission.mode < 0.5) {\n alpha = (1.0 - t) * (1.0 - t);\n } else {\n alpha = min(t * 6.0, 1.0) * (1.0 - smoothstep(0.9, 1.0, t));\n }\n alpha *= 0.7 + 0.3 * sin(rand.z * 251.0 + elapsed * 24.0); // sparkle flicker\n let alive = select(0.0, 1.0, elapsed >= rand.x && t < 1.0);\n\n let size = emission.size * rand.w * (0.7 + 0.6 * (1.0 - t)) * alive;\n // Camera-facing quad from the view matrix's right/up rows.\n let right = vec3f(camera.view[0][0], camera.view[1][0], camera.view[2][0]);\n let up = vec3f(camera.view[0][1], camera.view[1][1], camera.view[2][1]);\n var corner = vec2f(-1.0, -1.0);\n switch (vi) {\n case 1u, 4u: { corner = vec2f(1.0, -1.0); }\n case 2u, 3u: { corner = vec2f(-1.0, 1.0); }\n case 5u: { corner = vec2f(1.0, 1.0); }\n default: {}\n }\n let world = world0 + (right * corner.x + up * corner.y) * size;\n o.position = camera.projection * camera.view * vec4f(world, 1.0);\n o.uv = corner;\n o.fade = alpha;\n return o;\n}\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: vec2f, // r = bloom mask, g = scene alpha (HDR has none)\n};\n\n@fragment\nfn fs_main(i: VSOut) -> FSOut {\n let d2 = dot(i.uv, i.uv);\n if (d2 > 1.0) { discard; }\n var o: FSOut;\n // Quadratic falloff to EXACT zero at the rim (a gaussian alone leaves a\n // visible cutoff ring at the quad edge) with a hot bloom-feeding center.\n let t = 1.0 - d2;\n let core = t * t * (0.3 + 0.7 * exp(-d2 * 6.0));\n let w = core * i.fade;\n o.color = vec4f(emission.color * w, 0.0);\n o.mask = vec2f(w, w);\n return o;\n}\n";
2
- //# sourceMappingURL=particles.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,qBAAqB,88GA4FjC,CAAA"}
@@ -1,99 +0,0 @@
1
- // Stateless one-shot particle pass: every particle's position at time t is a
2
- // pure function of its static per-instance attributes — no compute, no
3
- // ping-pong buffers, nothing to update per frame but one time uniform.
4
- // Draws additively into the HDR target inside the main pass (after the
5
- // transparent phase), writing bloom mask + alpha so the sparks glow through
6
- // the existing pyramid and survive the composite.
7
- export const PARTICLES_SHADER_WGSL = /* wgsl */ `
8
- struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
9
- struct EmissionUniforms {
10
- color: vec3f, size: f32,
11
- centroidPos: vec3f, mode: f32, // 0 = burst (point -> scatter), 1 = converge (scatter -> point)
12
- startTime: f32, duration: f32, lift: f32, swirl: f32,
13
- };
14
- struct TimeUniforms { now: f32, };
15
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
16
- @group(0) @binding(1) var<uniform> emission: EmissionUniforms;
17
- @group(0) @binding(2) var<uniform> timeU: TimeUniforms;
18
-
19
- struct VSOut {
20
- @builtin(position) position: vec4f,
21
- @location(0) uv: vec2f,
22
- @location(1) fade: f32,
23
- };
24
-
25
- fn easeOutCubic(t: f32) -> f32 { let u = 1.0 - t; return 1.0 - u * u * u; }
26
- fn easeInOut(t: f32) -> f32 { return t * t * (3.0 - 2.0 * t); }
27
-
28
- @vertex
29
- fn vs_main(
30
- @builtin(vertex_index) vi: u32,
31
- @location(0) point: vec3f, // mesh anchor (spawn for burst, target for converge)
32
- @location(1) scatter: vec3f, // pre-scaled scatter offset at the far end
33
- @location(2) rand: vec4f, // delay, lifeScale, seed, sizeScale
34
- ) -> VSOut {
35
- var o: VSOut;
36
- let elapsed = timeU.now - emission.startTime;
37
- let life = max(emission.duration * rand.y, 1e-3);
38
- let t = clamp((elapsed - rand.x) / life, 0.0, 1.0);
39
-
40
- // Flight progress k runs point->scatter for burst, the reverse for converge;
41
- // the whole cloud winds around its centroid as it goes.
42
- var k: f32;
43
- if (emission.mode < 0.5) { k = easeOutCubic(t); } else { k = 1.0 - easeInOut(t); }
44
- let base = point + scatter * k + vec3f(0.0, emission.lift * k, 0.0);
45
- let rel = base - emission.centroidPos;
46
- let a = emission.swirl * k;
47
- let c = cos(a);
48
- let s = sin(a);
49
- let world0 = emission.centroidPos + vec3f(rel.x * c + rel.z * s, rel.y, -rel.x * s + rel.z * c);
50
-
51
- // Burst dies as it scatters; converge lights up fast and vanishes on arrival
52
- // (the reveal underneath takes over).
53
- var alpha: f32;
54
- if (emission.mode < 0.5) {
55
- alpha = (1.0 - t) * (1.0 - t);
56
- } else {
57
- alpha = min(t * 6.0, 1.0) * (1.0 - smoothstep(0.9, 1.0, t));
58
- }
59
- alpha *= 0.7 + 0.3 * sin(rand.z * 251.0 + elapsed * 24.0); // sparkle flicker
60
- let alive = select(0.0, 1.0, elapsed >= rand.x && t < 1.0);
61
-
62
- let size = emission.size * rand.w * (0.7 + 0.6 * (1.0 - t)) * alive;
63
- // Camera-facing quad from the view matrix's right/up rows.
64
- let right = vec3f(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
65
- let up = vec3f(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
66
- var corner = vec2f(-1.0, -1.0);
67
- switch (vi) {
68
- case 1u, 4u: { corner = vec2f(1.0, -1.0); }
69
- case 2u, 3u: { corner = vec2f(-1.0, 1.0); }
70
- case 5u: { corner = vec2f(1.0, 1.0); }
71
- default: {}
72
- }
73
- let world = world0 + (right * corner.x + up * corner.y) * size;
74
- o.position = camera.projection * camera.view * vec4f(world, 1.0);
75
- o.uv = corner;
76
- o.fade = alpha;
77
- return o;
78
- }
79
-
80
- struct FSOut {
81
- @location(0) color: vec4f,
82
- @location(1) mask: vec2f, // r = bloom mask, g = scene alpha (HDR has none)
83
- };
84
-
85
- @fragment
86
- fn fs_main(i: VSOut) -> FSOut {
87
- let d2 = dot(i.uv, i.uv);
88
- if (d2 > 1.0) { discard; }
89
- var o: FSOut;
90
- // Quadratic falloff to EXACT zero at the rim (a gaussian alone leaves a
91
- // visible cutoff ring at the quad edge) with a hot bloom-feeding center.
92
- let t = 1.0 - d2;
93
- let core = t * t * (0.3 + 0.7 * exp(-d2 * 6.0));
94
- let w = core * i.fade;
95
- o.color = vec4f(emission.color * w, 0.0);
96
- o.mask = vec2f(w, w);
97
- return o;
98
- }
99
- `;
package/src/particles.ts DELETED
@@ -1,232 +0,0 @@
1
- // One-shot GPU particle emissions (model dissolve/materialize, hit sparks…).
2
- // Stateless by construction: emit() uploads static per-particle attributes
3
- // once, the vertex shader evaluates each particle's whole flight from the
4
- // elapsed time, and the emission frees itself when it expires. Zero per-frame
5
- // CPU work beyond a single time-uniform write shared by every emission.
6
-
7
- import { Vec3 } from "./math"
8
- import { PARTICLES_SHADER_WGSL } from "./shaders/passes/particles"
9
-
10
- export interface ParticleEmitOptions {
11
- /** World-space anchor points, xyz-packed (see Model.sampleSurfacePoints).
12
- * One particle spawns per point. */
13
- points: Float32Array
14
- /** "burst" flies point → scattered and fades (dissolve); "converge" flies
15
- * scattered → point and vanishes on arrival (materialize). Default burst. */
16
- mode?: "burst" | "converge"
17
- /** Seconds the full effect lasts, staggers included (default 1.6). */
18
- duration?: number
19
- /** HDR color — components above 1 glow through bloom (default warm gold). */
20
- color?: Vec3
21
- /** Particle radius in world units (default 0.5). */
22
- size?: number
23
- /** Scatter distance in world units at the far end of the flight (default 14). */
24
- scatter?: number
25
- /** Upward drift across the flight, world units (default 6). */
26
- lift?: number
27
- /** Radians the cloud winds around its centroid across the flight (default 2). */
28
- swirl?: number
29
- }
30
-
31
- interface Emission {
32
- particleBuffer: GPUBuffer
33
- uniformBuffer: GPUBuffer
34
- bindGroup: GPUBindGroup
35
- count: number
36
- endTime: number
37
- }
38
-
39
- /** Floats per particle: point.xyz, scatter.xyz, rand(delay, lifeScale, seed, sizeScale). */
40
- const PARTICLE_STRIDE = 10
41
-
42
- export class ParticleSystem {
43
- private readonly device: GPUDevice
44
- private readonly pipeline: GPURenderPipeline
45
- private readonly bindGroupLayout: GPUBindGroupLayout
46
- private readonly cameraBuffer: GPUBuffer
47
- private readonly timeBuffer: GPUBuffer
48
- private readonly timeData = new Float32Array(1)
49
- private emissions: Emission[] = []
50
-
51
- constructor(
52
- device: GPUDevice,
53
- cameraBuffer: GPUBuffer,
54
- formats: { hdr: GPUTextureFormat; mask: GPUTextureFormat; depth: GPUTextureFormat; sampleCount: number }
55
- ) {
56
- this.device = device
57
- this.cameraBuffer = cameraBuffer
58
- this.timeBuffer = device.createBuffer({
59
- label: "particle time",
60
- size: 16,
61
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
62
- })
63
- this.bindGroupLayout = device.createBindGroupLayout({
64
- label: "particle bind group layout",
65
- entries: [
66
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
67
- { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
68
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
69
- ],
70
- })
71
- const module = device.createShaderModule({ label: "particles", code: PARTICLES_SHADER_WGSL })
72
- const additive: GPUBlendState = {
73
- color: { srcFactor: "one", dstFactor: "one" },
74
- alpha: { srcFactor: "one", dstFactor: "one" },
75
- }
76
- this.pipeline = device.createRenderPipeline({
77
- label: "particle pipeline",
78
- layout: device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
79
- vertex: {
80
- module,
81
- entryPoint: "vs_main",
82
- buffers: [
83
- {
84
- arrayStride: PARTICLE_STRIDE * 4,
85
- stepMode: "instance",
86
- attributes: [
87
- { shaderLocation: 0, offset: 0, format: "float32x3" },
88
- { shaderLocation: 1, offset: 12, format: "float32x3" },
89
- { shaderLocation: 2, offset: 24, format: "float32x4" },
90
- ],
91
- },
92
- ],
93
- },
94
- fragment: {
95
- module,
96
- entryPoint: "fs_main",
97
- targets: [
98
- { format: formats.hdr, blend: additive },
99
- { format: formats.mask, blend: additive },
100
- ],
101
- },
102
- primitive: { topology: "triangle-list" },
103
- // Occluded by the scene, never occluding: glitter reads as light, and
104
- // light writing depth would punch holes into the transparent phase.
105
- depthStencil: { format: formats.depth, depthWriteEnabled: false, depthCompare: "less-equal" },
106
- multisample: { count: formats.sampleCount },
107
- })
108
- }
109
-
110
- /** Upload one emission. `now` is the shared clock (seconds) draw() receives. */
111
- emit(opts: ParticleEmitOptions, now: number): void {
112
- const count = Math.floor(opts.points.length / 3)
113
- if (count === 0) return
114
- const mode = opts.mode === "converge" ? 1 : 0
115
- const duration = opts.duration ?? 1.6
116
- const color = opts.color ?? new Vec3(4.0, 3.0, 1.6)
117
- const size = opts.size ?? 0.5
118
- const scatter = opts.scatter ?? 14
119
- const lift = opts.lift ?? 6
120
- const swirl = opts.swirl ?? 2
121
-
122
- // Delays stagger the front so the cloud dissolves as a sweep, and each
123
- // particle's life fits delay + flight inside the emission's duration.
124
- const data = new Float32Array(count * PARTICLE_STRIDE)
125
- let cx = 0
126
- let cy = 0
127
- let cz = 0
128
- for (let i = 0; i < count; i++) {
129
- const o = i * PARTICLE_STRIDE
130
- const px = opts.points[i * 3]
131
- const py = opts.points[i * 3 + 1]
132
- const pz = opts.points[i * 3 + 2]
133
- cx += px
134
- cy += py
135
- cz += pz
136
- data[o] = px
137
- data[o + 1] = py
138
- data[o + 2] = pz
139
- // Random direction on the sphere, mildly flattened so the cloud spreads
140
- // outward more than it dives into the floor.
141
- const theta = Math.random() * Math.PI * 2
142
- const zr = Math.random() * 2 - 1
143
- const r = Math.sqrt(Math.max(0, 1 - zr * zr))
144
- const dist = scatter * (0.35 + 0.65 * Math.random())
145
- data[o + 3] = Math.cos(theta) * r * dist
146
- data[o + 4] = zr * 0.5 * dist
147
- data[o + 5] = Math.sin(theta) * r * dist
148
- const delay = Math.random() * duration * 0.3
149
- data[o + 6] = delay
150
- data[o + 7] = (duration - delay) / duration // lifeScale: everyone lands inside duration
151
- data[o + 8] = Math.random()
152
- data[o + 9] = 0.5 + Math.random()
153
- }
154
-
155
- const particleBuffer = this.device.createBuffer({
156
- label: "particle emission",
157
- size: data.byteLength,
158
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
159
- })
160
- this.device.queue.writeBuffer(particleBuffer, 0, data)
161
-
162
- const uni = new Float32Array(12)
163
- uni[0] = color.x
164
- uni[1] = color.y
165
- uni[2] = color.z
166
- uni[3] = size
167
- uni[4] = cx / count
168
- uni[5] = cy / count
169
- uni[6] = cz / count
170
- uni[7] = mode
171
- uni[8] = now
172
- uni[9] = duration
173
- uni[10] = lift
174
- uni[11] = swirl
175
- const uniformBuffer = this.device.createBuffer({
176
- label: "particle emission uniforms",
177
- size: uni.byteLength,
178
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
179
- })
180
- this.device.queue.writeBuffer(uniformBuffer, 0, uni)
181
-
182
- const bindGroup = this.device.createBindGroup({
183
- label: "particle emission bind group",
184
- layout: this.bindGroupLayout,
185
- entries: [
186
- { binding: 0, resource: { buffer: this.cameraBuffer } },
187
- { binding: 1, resource: { buffer: uniformBuffer } },
188
- { binding: 2, resource: { buffer: this.timeBuffer } },
189
- ],
190
- })
191
-
192
- this.emissions.push({ particleBuffer, uniformBuffer, bindGroup, count, endTime: now + duration + 0.1 })
193
- }
194
-
195
- get active(): boolean {
196
- return this.emissions.length > 0
197
- }
198
-
199
- /** Record draws into the main pass (after the transparent phase). */
200
- draw(pass: GPURenderPassEncoder, now: number): void {
201
- if (this.emissions.length === 0) return
202
- const live: Emission[] = []
203
- let bound = false
204
- for (const e of this.emissions) {
205
- if (now >= e.endTime) {
206
- e.particleBuffer.destroy()
207
- e.uniformBuffer.destroy()
208
- continue
209
- }
210
- if (!bound) {
211
- this.timeData[0] = now
212
- this.device.queue.writeBuffer(this.timeBuffer, 0, this.timeData)
213
- pass.setPipeline(this.pipeline)
214
- bound = true
215
- }
216
- pass.setBindGroup(0, e.bindGroup)
217
- pass.setVertexBuffer(0, e.particleBuffer)
218
- pass.draw(6, e.count)
219
- live.push(e)
220
- }
221
- this.emissions = live
222
- }
223
-
224
- dispose(): void {
225
- for (const e of this.emissions) {
226
- e.particleBuffer.destroy()
227
- e.uniformBuffer.destroy()
228
- }
229
- this.emissions = []
230
- this.timeBuffer.destroy()
231
- }
232
- }
@@ -1,100 +0,0 @@
1
- // Stateless one-shot particle pass: every particle's position at time t is a
2
- // pure function of its static per-instance attributes — no compute, no
3
- // ping-pong buffers, nothing to update per frame but one time uniform.
4
- // Draws additively into the HDR target inside the main pass (after the
5
- // transparent phase), writing bloom mask + alpha so the sparks glow through
6
- // the existing pyramid and survive the composite.
7
-
8
- export const PARTICLES_SHADER_WGSL = /* wgsl */ `
9
- struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
10
- struct EmissionUniforms {
11
- color: vec3f, size: f32,
12
- centroidPos: vec3f, mode: f32, // 0 = burst (point -> scatter), 1 = converge (scatter -> point)
13
- startTime: f32, duration: f32, lift: f32, swirl: f32,
14
- };
15
- struct TimeUniforms { now: f32, };
16
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
17
- @group(0) @binding(1) var<uniform> emission: EmissionUniforms;
18
- @group(0) @binding(2) var<uniform> timeU: TimeUniforms;
19
-
20
- struct VSOut {
21
- @builtin(position) position: vec4f,
22
- @location(0) uv: vec2f,
23
- @location(1) fade: f32,
24
- };
25
-
26
- fn easeOutCubic(t: f32) -> f32 { let u = 1.0 - t; return 1.0 - u * u * u; }
27
- fn easeInOut(t: f32) -> f32 { return t * t * (3.0 - 2.0 * t); }
28
-
29
- @vertex
30
- fn vs_main(
31
- @builtin(vertex_index) vi: u32,
32
- @location(0) point: vec3f, // mesh anchor (spawn for burst, target for converge)
33
- @location(1) scatter: vec3f, // pre-scaled scatter offset at the far end
34
- @location(2) rand: vec4f, // delay, lifeScale, seed, sizeScale
35
- ) -> VSOut {
36
- var o: VSOut;
37
- let elapsed = timeU.now - emission.startTime;
38
- let life = max(emission.duration * rand.y, 1e-3);
39
- let t = clamp((elapsed - rand.x) / life, 0.0, 1.0);
40
-
41
- // Flight progress k runs point->scatter for burst, the reverse for converge;
42
- // the whole cloud winds around its centroid as it goes.
43
- var k: f32;
44
- if (emission.mode < 0.5) { k = easeOutCubic(t); } else { k = 1.0 - easeInOut(t); }
45
- let base = point + scatter * k + vec3f(0.0, emission.lift * k, 0.0);
46
- let rel = base - emission.centroidPos;
47
- let a = emission.swirl * k;
48
- let c = cos(a);
49
- let s = sin(a);
50
- let world0 = emission.centroidPos + vec3f(rel.x * c + rel.z * s, rel.y, -rel.x * s + rel.z * c);
51
-
52
- // Burst dies as it scatters; converge lights up fast and vanishes on arrival
53
- // (the reveal underneath takes over).
54
- var alpha: f32;
55
- if (emission.mode < 0.5) {
56
- alpha = (1.0 - t) * (1.0 - t);
57
- } else {
58
- alpha = min(t * 6.0, 1.0) * (1.0 - smoothstep(0.9, 1.0, t));
59
- }
60
- alpha *= 0.7 + 0.3 * sin(rand.z * 251.0 + elapsed * 24.0); // sparkle flicker
61
- let alive = select(0.0, 1.0, elapsed >= rand.x && t < 1.0);
62
-
63
- let size = emission.size * rand.w * (0.7 + 0.6 * (1.0 - t)) * alive;
64
- // Camera-facing quad from the view matrix's right/up rows.
65
- let right = vec3f(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
66
- let up = vec3f(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
67
- var corner = vec2f(-1.0, -1.0);
68
- switch (vi) {
69
- case 1u, 4u: { corner = vec2f(1.0, -1.0); }
70
- case 2u, 3u: { corner = vec2f(-1.0, 1.0); }
71
- case 5u: { corner = vec2f(1.0, 1.0); }
72
- default: {}
73
- }
74
- let world = world0 + (right * corner.x + up * corner.y) * size;
75
- o.position = camera.projection * camera.view * vec4f(world, 1.0);
76
- o.uv = corner;
77
- o.fade = alpha;
78
- return o;
79
- }
80
-
81
- struct FSOut {
82
- @location(0) color: vec4f,
83
- @location(1) mask: vec2f, // r = bloom mask, g = scene alpha (HDR has none)
84
- };
85
-
86
- @fragment
87
- fn fs_main(i: VSOut) -> FSOut {
88
- let d2 = dot(i.uv, i.uv);
89
- if (d2 > 1.0) { discard; }
90
- var o: FSOut;
91
- // Quadratic falloff to EXACT zero at the rim (a gaussian alone leaves a
92
- // visible cutoff ring at the quad edge) with a hot bloom-feeding center.
93
- let t = 1.0 - d2;
94
- let core = t * t * (0.3 + 0.7 * exp(-d2 * 6.0));
95
- let w = core * i.fade;
96
- o.color = vec4f(emission.color * w, 0.0);
97
- o.mask = vec2f(w, w);
98
- return o;
99
- }
100
- `