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/README.md +8 -5
- package/dist/engine.d.ts +96 -39
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +246 -85
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +10 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/model.d.ts +0 -5
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +0 -36
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +21 -0
- package/dist/shaders/passes/composite.d.ts +27 -12
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +191 -43
- package/package.json +1 -1
- package/src/engine.ts +286 -93
- package/src/graph/registry.ts +11 -0
- package/src/index.ts +4 -3
- package/src/model.ts +0 -36
- package/src/shaders/materials/nodes.ts +21 -0
- package/src/shaders/passes/composite.ts +218 -59
- package/dist/particles.d.ts +0 -43
- package/dist/particles.d.ts.map +0 -1
- package/dist/particles.js +0 -183
- package/dist/shaders/passes/particles.d.ts +0 -2
- package/dist/shaders/passes/particles.d.ts.map +0 -1
- package/dist/shaders/passes/particles.js +0 -99
- package/src/particles.ts +0 -232
- package/src/shaders/passes/particles.ts +0 -100
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
|
-
`
|