reze-engine 0.37.0 → 0.39.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.
@@ -0,0 +1,43 @@
1
+ import { Vec3 } from "./math";
2
+ export interface ParticleEmitOptions {
3
+ /** World-space anchor points, xyz-packed (see Model.sampleSurfacePoints).
4
+ * One particle spawns per point. */
5
+ points: Float32Array;
6
+ /** "burst" flies point → scattered and fades (dissolve); "converge" flies
7
+ * scattered → point and vanishes on arrival (materialize). Default burst. */
8
+ mode?: "burst" | "converge";
9
+ /** Seconds the full effect lasts, staggers included (default 1.6). */
10
+ duration?: number;
11
+ /** HDR color — components above 1 glow through bloom (default warm gold). */
12
+ color?: Vec3;
13
+ /** Particle radius in world units (default 0.5). */
14
+ size?: number;
15
+ /** Scatter distance in world units at the far end of the flight (default 14). */
16
+ scatter?: number;
17
+ /** Upward drift across the flight, world units (default 6). */
18
+ lift?: number;
19
+ /** Radians the cloud winds around its centroid across the flight (default 2). */
20
+ swirl?: number;
21
+ }
22
+ export declare class ParticleSystem {
23
+ private readonly device;
24
+ private readonly pipeline;
25
+ private readonly bindGroupLayout;
26
+ private readonly cameraBuffer;
27
+ private readonly timeBuffer;
28
+ private readonly timeData;
29
+ private emissions;
30
+ constructor(device: GPUDevice, cameraBuffer: GPUBuffer, formats: {
31
+ hdr: GPUTextureFormat;
32
+ mask: GPUTextureFormat;
33
+ depth: GPUTextureFormat;
34
+ sampleCount: number;
35
+ });
36
+ /** Upload one emission. `now` is the shared clock (seconds) draw() receives. */
37
+ emit(opts: ParticleEmitOptions, now: number): void;
38
+ get active(): boolean;
39
+ /** Record draws into the main pass (after the transparent phase). */
40
+ draw(pass: GPURenderPassEncoder, now: number): void;
41
+ dispose(): void;
42
+ }
43
+ //# sourceMappingURL=particles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../src/particles.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAG7B,MAAM,WAAW,mBAAmB;IAClC;yCACqC;IACrC,MAAM,EAAE,YAAY,CAAA;IACpB;kFAC8E;IAC9E,IAAI,CAAC,EAAE,OAAO,GAAG,UAAU,CAAA;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,IAAI,CAAA;IACZ,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAaD,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAmB;IAC5C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoB;IACpD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAW;IACxC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAW;IACtC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsB;IAC/C,OAAO,CAAC,SAAS,CAAiB;gBAGhC,MAAM,EAAE,SAAS,EACjB,YAAY,EAAE,SAAS,EACvB,OAAO,EAAE;QAAE,GAAG,EAAE,gBAAgB,CAAC;QAAC,IAAI,EAAE,gBAAgB,CAAC;QAAC,KAAK,EAAE,gBAAgB,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE;IAwD1G,gFAAgF;IAChF,IAAI,CAAC,IAAI,EAAE,mBAAmB,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAoFlD,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,qEAAqE;IACrE,IAAI,CAAC,IAAI,EAAE,oBAAoB,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAwBnD,OAAO,IAAI,IAAI;CAQhB"}
@@ -0,0 +1,183 @@
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
+ }
@@ -0,0 +1,2 @@
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,qBAAqB,88GA4FjC,CAAA"}
@@ -0,0 +1,99 @@
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
+ `;
@@ -46,8 +46,12 @@ export declare class AnimationStateMachine {
46
46
  get state(): string;
47
47
  /** Seconds the current state has been active. */
48
48
  get stateTime(): number;
49
- /** Force a transition now, regardless of the transition table. */
50
- go(to: string, fade?: number): void;
49
+ /** Force a transition now, regardless of the transition table. `atTime`
50
+ * enters the state with its clock already at that many seconds — for
51
+ * handoffs that must continue a clip mid-flight (e.g. swapping model
52
+ * skins mid-action: the new machine picks up exactly where the old
53
+ * one's state stood). */
54
+ go(to: string, fade?: number, atTime?: number): void;
51
55
  update(dt: number): void;
52
56
  private transitionReady;
53
57
  private begin;
@@ -1 +1 @@
1
- {"version":3,"file":"state-machine.d.ts","sourceRoot":"","sources":["../src/state-machine.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AACpC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAK7C,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,2EAA2E;IAC3E,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;6EAEyE;IACzE,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,UAAU,EAAE,GAAG,IAAI,CAAA;IAC7C,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IACvC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,IAAI,EAAE,MAAM,GAAG,GAAG,CAAA;IAClB,EAAE,EAAE,MAAM,CAAA;IACV,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,OAAO,CAAA;IACpB;;;+BAG2B;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,0DAA0D;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAQD,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAO;IAC7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA8B;IACrD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAQ;IAEpC,OAAO,CAAC,OAAO,CAAa;IAC5B,wEAAwE;IACxE,OAAO,CAAC,MAAM,CAAyE;IAIvF,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmB;gBAE9B,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,WAAW,EAAE,iBAAiB,EAAE,EAAE,OAAO,EAAE,mBAAmB;IAW9H,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,iDAAiD;IACjD,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,kEAAkE;IAClE,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;IAInC,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAgCxB,OAAO,CAAC,eAAe;IAgBvB,OAAO,CAAC,KAAK;IAYb;yEACqE;IACrE,OAAO,CAAC,OAAO;IAaf,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmD;IAC7E,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAmD;IAE9E;kDAC8C;IAC9C,OAAO,CAAC,KAAK;IAyBb,OAAO,CAAC,YAAY;CAIrB"}
1
+ {"version":3,"file":"state-machine.d.ts","sourceRoot":"","sources":["../src/state-machine.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AACpC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAK7C,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,2EAA2E;IAC3E,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;6EAEyE;IACzE,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,UAAU,EAAE,GAAG,IAAI,CAAA;IAC7C,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IACvC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,IAAI,EAAE,MAAM,GAAG,GAAG,CAAA;IAClB,EAAE,EAAE,MAAM,CAAA;IACV,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,OAAO,CAAA;IACpB;;;+BAG2B;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,0DAA0D;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAQD,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAO;IAC7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA8B;IACrD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAQ;IAEpC,OAAO,CAAC,OAAO,CAAa;IAC5B,wEAAwE;IACxE,OAAO,CAAC,MAAM,CAAyE;IAIvF,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmB;gBAE9B,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,WAAW,EAAE,iBAAiB,EAAE,EAAE,OAAO,EAAE,mBAAmB;IAW9H,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,iDAAiD;IACjD,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED;;;;8BAI0B;IAC1B,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAIpD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAgCxB,OAAO,CAAC,eAAe;IAgBvB,OAAO,CAAC,KAAK;IAYb;yEACqE;IACrE,OAAO,CAAC,OAAO;IAaf,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmD;IAC7E,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAmD;IAE9E;kDAC8C;IAC9C,OAAO,CAAC,KAAK;IAyBb,OAAO,CAAC,YAAY;CAIrB"}
@@ -26,9 +26,13 @@ export class AnimationStateMachine {
26
26
  get stateTime() {
27
27
  return this.current.time;
28
28
  }
29
- /** Force a transition now, regardless of the transition table. */
30
- go(to, fade) {
31
- this.begin(to, fade ?? this.defaultFade);
29
+ /** Force a transition now, regardless of the transition table. `atTime`
30
+ * enters the state with its clock already at that many seconds — for
31
+ * handoffs that must continue a clip mid-flight (e.g. swapping model
32
+ * skins mid-action: the new machine picks up exactly where the old
33
+ * one's state stood). */
34
+ go(to, fade, atTime) {
35
+ this.begin(to, fade ?? this.defaultFade, atTime);
32
36
  }
33
37
  update(dt) {
34
38
  this.current.time += dt;
@@ -79,7 +83,7 @@ export class AnimationStateMachine {
79
83
  }
80
84
  return true;
81
85
  }
82
- begin(to, fade) {
86
+ begin(to, fade, atTime = 0) {
83
87
  const def = this.states[to];
84
88
  if (!def)
85
89
  throw new Error(`Unknown state "${to}"`);
@@ -88,7 +92,7 @@ export class AnimationStateMachine {
88
92
  // three-way mixes) — the current state becomes the outgoing one.
89
93
  this.fading = fade > 0 ? { state: this.current, elapsed: 0, duration: fade } : null;
90
94
  const from = this.current.name;
91
- this.current = { name: to, def, time: 0 };
95
+ this.current = { name: to, def, time: atTime };
92
96
  def.onEnter?.(from);
93
97
  }
94
98
  /** This frame's entries for a state: clip states own a one-entry pose;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -5,6 +5,7 @@ import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
5
5
  import { decodeTga } from "./tga-loader"
6
6
  import { VMDLoader } from "./vmd-loader"
7
7
  import { CameraAnimation } from "./camera-animation"
8
+ import { ParticleSystem, type ParticleEmitOptions } from "./particles"
8
9
  import { PmxLoader } from "./pmx-loader"
9
10
  import { RezePhysics } from "./physics"
10
11
  import type { WindOptions } from "./physics/world"
@@ -110,7 +111,8 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
110
111
  "带", // straps and bands: 头带/发带/背带/腰带
111
112
  "绳", // ropes: 背绳/腰绳
112
113
  "纱", // gauze/veils: 头纱
113
- "肩布", // shoulder cloth/drape
114
+ "巾", // kerchiefs/scarves: 头巾/领巾/围巾
115
+ "布", // cloth panels: 肩布/腰布
114
116
  "背球", // back ornament sphere
115
117
  "腰花", // waist flower
116
118
  "花蕊", // flower pistil ornament
@@ -716,6 +718,8 @@ export class Engine {
716
718
  private multisampleMaskTexture!: GPUTexture
717
719
  private maskResolveTexture!: GPUTexture
718
720
  private maskResolveView!: GPUTextureView
721
+ /** One-shot particle emissions (emitParticles); lazily built on first emit. */
722
+ private particles: ParticleSystem | null = null
719
723
  private renderPassDescriptor!: GPURenderPassDescriptor
720
724
  private compositePassDescriptor!: GPURenderPassDescriptor
721
725
  // Two specialized composite pipelines via WGSL pipeline-override constants.
@@ -2909,6 +2913,33 @@ export class Engine {
2909
2913
  setCameraBeta(b: number): void {
2910
2914
  this.camera.beta = b
2911
2915
  }
2916
+ /** Vertical field of view in radians (default π/4). While a camera VMD
2917
+ * drives the view it animates fov itself; the orbit value set here is
2918
+ * restored when the VMD releases the camera. */
2919
+ getCameraFov(): number {
2920
+ return this.camera.fov
2921
+ }
2922
+ setCameraFov(fov: number): void {
2923
+ this.camera.fov = fov
2924
+ }
2925
+
2926
+ /** Fire a one-shot particle effect (see ParticleEmitOptions): "burst" scatters
2927
+ * glowing sparks from the given world points, "converge" condenses them onto
2928
+ * the points. Pair with Model.sampleSurfacePoints for dissolve/materialize
2929
+ * transitions shaped like the model. Stateless GPU particles — the emission
2930
+ * cleans itself up when it expires. */
2931
+ emitParticles(opts: ParticleEmitOptions): void {
2932
+ if (!this.device) return
2933
+ if (!this.particles) {
2934
+ this.particles = new ParticleSystem(this.device, this.cameraUniformBuffer, {
2935
+ hdr: this.hdrFormat,
2936
+ mask: Engine.BLOOM_MASK_FORMAT,
2937
+ depth: "depth24plus-stencil8",
2938
+ sampleCount: Engine.MULTISAMPLE_COUNT,
2939
+ })
2940
+ }
2941
+ this.particles.emit(opts, performance.now() / 1000)
2942
+ }
2912
2943
 
2913
2944
  // Step 5: Create lighting buffers
2914
2945
  private setupLighting() {
@@ -3065,6 +3096,8 @@ export class Engine {
3065
3096
  this.forEachInstance((inst) => inst.model.stop())
3066
3097
  if (Engine.instance === this) Engine.instance = null
3067
3098
  if (this.camera) this.camera.detachControl()
3099
+ this.particles?.dispose()
3100
+ this.particles = null
3068
3101
 
3069
3102
  // Remove raycasting event listeners
3070
3103
  if (this.onRaycast) {
@@ -3466,7 +3499,11 @@ export class Engine {
3466
3499
  } else if (verticesChanged) {
3467
3500
  inst.vertexBufferNeedsUpdate = true
3468
3501
  }
3469
- if (inst.physics && this.physicsEnabled) {
3502
+ // Hidden models keep animating (cheap, and a reveal must not pop a stale
3503
+ // pose) but skip cloth simulation entirely — a roster of resident
3504
+ // alternate skins would otherwise pay full physics for invisible cloth.
3505
+ // Hosts that reveal after long hiding reset physics anyway (resetPhysics).
3506
+ if (inst.physics && this.physicsEnabled && inst.model.visible) {
3470
3507
  const tPhys = performance.now()
3471
3508
  inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices())
3472
3509
  physicsMs += performance.now() - tPhys
@@ -5102,6 +5139,9 @@ export class Engine {
5102
5139
  this.forEachInstance((inst) => {
5103
5140
  if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
5104
5141
  })
5142
+ // Particle emissions last: additive light over everything, depth-tested
5143
+ // against the scene, feeding the bloom mask so the sparks glow.
5144
+ this.particles?.draw(pass, performance.now() / 1000)
5105
5145
  pass.end()
5106
5146
 
5107
5147
  // Bloom pyramid (EEVEE 3.6):
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ export {
57
57
  MATERIAL_MORPH_MULTIPLY,
58
58
  MATERIAL_MORPH_ADD,
59
59
  type ClipEventInfo,
60
+ type RootMotionProfile,
60
61
  type Morph,
61
62
  type Morphing,
62
63
  type BoneMorphOffset,
@@ -97,4 +98,5 @@ export { VMDWriter } from "./vmd-writer"
97
98
  export { PmxLoader } from "./pmx-loader"
98
99
  export { CameraAnimation, type CameraPose } from "./camera-animation"
99
100
  export { RezePhysics } from "./physics"
100
- export type { WindOptions } from "./physics/world"
101
+ export type { WindOptions } from "./physics/world"
102
+ export { type ParticleEmitOptions } from "./particles"
package/src/locomotion.ts CHANGED
@@ -291,6 +291,34 @@ export class LocomotionController {
291
291
  this.yaw = yaw
292
292
  }
293
293
 
294
+ /** Teleport PLUS a hard reset of every transient motion commitment: any
295
+ * in-flight authored stop/turn (whose stored start position would otherwise
296
+ * keep driving the root from where it began), the exit ghost, speed and
297
+ * momentum, the heading-hold gate, and the held inputs. For handing the
298
+ * root back after an externally-driven action (a root-motion clip state):
299
+ * wherever the action ended is simply where she now stands. */
300
+ reset(x: number, y: number, z: number, yaw = 0): void {
301
+ this.position.setXYZ(x, y, z)
302
+ this.yaw = wrapAngle(yaw)
303
+ this.dirX = Math.sin(this.yaw)
304
+ this.dirZ = Math.cos(this.yaw)
305
+ this.speedLevel = 0
306
+ this.recentSpeed = 0
307
+ this.headingHold = 0
308
+ this.headingDirX = 0
309
+ this.headingDirY = 0
310
+ this.gaitPhase = 0
311
+ this.stopping = null
312
+ this.turning = null
313
+ this.runTurning = null
314
+ this.exitGhost = null
315
+ this.inputX = 0
316
+ this.inputY = 0
317
+ this.inputSprint = false
318
+ this.inputForward = 0
319
+ this.inputSteer = 0
320
+ }
321
+
294
322
  /** Strafe mode: hold the body at this world yaw (a camera forward, a lock-on target)
295
323
  * while setMove's vector drives the directional strafe ring — requires
296
324
  * clips.strafeRun. null returns to turn-toward-movement. */