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.
- package/README.md +10 -2
- package/dist/engine.d.ts +14 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +42 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/locomotion.d.ts +7 -0
- package/dist/locomotion.d.ts.map +1 -1
- package/dist/locomotion.js +27 -0
- package/dist/model.d.ts +45 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +118 -0
- package/dist/particles.d.ts +43 -0
- package/dist/particles.d.ts.map +1 -0
- package/dist/particles.js +183 -0
- package/dist/shaders/passes/particles.d.ts +2 -0
- package/dist/shaders/passes/particles.d.ts.map +1 -0
- package/dist/shaders/passes/particles.js +99 -0
- package/dist/state-machine.d.ts +6 -2
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +9 -5
- package/package.json +1 -1
- package/src/engine.ts +42 -2
- package/src/index.ts +3 -1
- package/src/locomotion.ts +28 -0
- package/src/model.ts +125 -0
- package/src/particles.ts +232 -0
- package/src/shaders/passes/particles.ts +100 -0
- package/src/state-machine.ts +9 -5
|
@@ -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
|
+
`;
|
package/dist/state-machine.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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"}
|
package/dist/state-machine.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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:
|
|
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
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
|
-
"
|
|
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
|
-
|
|
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. */
|