reze-engine 0.2.13 → 0.2.15
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 +71 -71
- package/dist/engine.d.ts +11 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +486 -372
- package/package.json +1 -1
- package/src/camera.ts +358 -358
- package/src/engine.ts +2544 -2404
- package/src/math.ts +546 -546
- package/src/model.ts +421 -421
- package/src/physics.ts +752 -752
- package/src/pmx-loader.ts +1054 -1054
- package/src/vmd-loader.ts +179 -179
- package/dist/pool.d.ts +0 -38
- package/dist/pool.d.ts.map +0 -1
- package/dist/pool.js +0 -422
package/dist/engine.js
CHANGED
|
@@ -18,6 +18,7 @@ export class Engine {
|
|
|
18
18
|
this.BLOOM_DOWNSCALE_FACTOR = 2;
|
|
19
19
|
// Ambient light settings
|
|
20
20
|
this.ambient = 1.0;
|
|
21
|
+
this.ambientColor = new Vec3(1.0, 1.0, 1.0);
|
|
21
22
|
// Bloom settings
|
|
22
23
|
this.bloomThreshold = 0.3;
|
|
23
24
|
this.bloomIntensity = 0.12;
|
|
@@ -55,9 +56,12 @@ export class Engine {
|
|
|
55
56
|
this.gpuMemoryMB = 0;
|
|
56
57
|
this.hasAnimation = false; // Set to true when loadAnimation is called
|
|
57
58
|
this.playingAnimation = false; // Set to true when playAnimation is called
|
|
59
|
+
this.breathingTimeout = null;
|
|
60
|
+
this.breathingBaseRotations = new Map();
|
|
58
61
|
this.canvas = canvas;
|
|
59
62
|
if (options) {
|
|
60
63
|
this.ambient = options.ambient ?? 1.0;
|
|
64
|
+
this.ambientColor = options.ambientColor ?? new Vec3(1.0, 1.0, 1.0);
|
|
61
65
|
this.bloomIntensity = options.bloomIntensity ?? 0.12;
|
|
62
66
|
this.rimLightIntensity = options.rimLightIntensity ?? 0.45;
|
|
63
67
|
this.cameraDistance = options.cameraDistance ?? 26.6;
|
|
@@ -99,121 +103,122 @@ export class Engine {
|
|
|
99
103
|
});
|
|
100
104
|
const shaderModule = this.device.createShaderModule({
|
|
101
105
|
label: "model shaders",
|
|
102
|
-
code: /* wgsl */ `
|
|
103
|
-
struct CameraUniforms {
|
|
104
|
-
view: mat4x4f,
|
|
105
|
-
projection: mat4x4f,
|
|
106
|
-
viewPos: vec3f,
|
|
107
|
-
_padding: f32,
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
struct Light {
|
|
111
|
-
direction: vec3f,
|
|
112
|
-
_padding1: f32,
|
|
113
|
-
color: vec3f,
|
|
114
|
-
intensity: f32,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
struct LightUniforms {
|
|
118
|
-
ambient: f32,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
@
|
|
137
|
-
@location(
|
|
138
|
-
@location(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
@group(0) @binding(
|
|
143
|
-
@group(0) @binding(
|
|
144
|
-
@group(0) @binding(
|
|
145
|
-
@group(0) @binding(
|
|
146
|
-
@group(0) @binding(
|
|
147
|
-
@group(0) @binding(
|
|
148
|
-
@group(0) @binding(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
@location(
|
|
153
|
-
@location(
|
|
154
|
-
@location(
|
|
155
|
-
@location(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
let
|
|
163
|
-
let
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
var
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
let
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
output.
|
|
178
|
-
output.
|
|
179
|
-
output.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
let
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
let
|
|
201
|
-
let
|
|
202
|
-
let
|
|
203
|
-
let
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
rimFactor =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
106
|
+
code: /* wgsl */ `
|
|
107
|
+
struct CameraUniforms {
|
|
108
|
+
view: mat4x4f,
|
|
109
|
+
projection: mat4x4f,
|
|
110
|
+
viewPos: vec3f,
|
|
111
|
+
_padding: f32,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
struct Light {
|
|
115
|
+
direction: vec3f,
|
|
116
|
+
_padding1: f32,
|
|
117
|
+
color: vec3f,
|
|
118
|
+
intensity: f32,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
struct LightUniforms {
|
|
122
|
+
ambient: f32,
|
|
123
|
+
ambientColor: vec3f,
|
|
124
|
+
lightCount: f32,
|
|
125
|
+
_padding1: f32,
|
|
126
|
+
_padding2: f32,
|
|
127
|
+
lights: array<Light, 4>,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
struct MaterialUniforms {
|
|
131
|
+
alpha: f32,
|
|
132
|
+
alphaMultiplier: f32,
|
|
133
|
+
rimIntensity: f32,
|
|
134
|
+
_padding1: f32,
|
|
135
|
+
rimColor: vec3f,
|
|
136
|
+
isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
struct VertexOutput {
|
|
140
|
+
@builtin(position) position: vec4f,
|
|
141
|
+
@location(0) normal: vec3f,
|
|
142
|
+
@location(1) uv: vec2f,
|
|
143
|
+
@location(2) worldPos: vec3f,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
147
|
+
@group(0) @binding(1) var<uniform> light: LightUniforms;
|
|
148
|
+
@group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
|
|
149
|
+
@group(0) @binding(3) var diffuseSampler: sampler;
|
|
150
|
+
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
151
|
+
@group(0) @binding(5) var toonTexture: texture_2d<f32>;
|
|
152
|
+
@group(0) @binding(6) var toonSampler: sampler;
|
|
153
|
+
@group(0) @binding(7) var<uniform> material: MaterialUniforms;
|
|
154
|
+
|
|
155
|
+
@vertex fn vs(
|
|
156
|
+
@location(0) position: vec3f,
|
|
157
|
+
@location(1) normal: vec3f,
|
|
158
|
+
@location(2) uv: vec2f,
|
|
159
|
+
@location(3) joints0: vec4<u32>,
|
|
160
|
+
@location(4) weights0: vec4<f32>
|
|
161
|
+
) -> VertexOutput {
|
|
162
|
+
var output: VertexOutput;
|
|
163
|
+
let pos4 = vec4f(position, 1.0);
|
|
164
|
+
|
|
165
|
+
// Branchless weight normalization (avoids GPU branch divergence)
|
|
166
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
167
|
+
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
168
|
+
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
169
|
+
|
|
170
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
171
|
+
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
172
|
+
for (var i = 0u; i < 4u; i++) {
|
|
173
|
+
let j = joints0[i];
|
|
174
|
+
let w = normalizedWeights[i];
|
|
175
|
+
let m = skinMats[j];
|
|
176
|
+
skinnedPos += (m * pos4) * w;
|
|
177
|
+
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
178
|
+
skinnedNrm += (r3 * normal) * w;
|
|
179
|
+
}
|
|
180
|
+
let worldPos = skinnedPos.xyz;
|
|
181
|
+
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
182
|
+
output.normal = normalize(skinnedNrm);
|
|
183
|
+
output.uv = uv;
|
|
184
|
+
output.worldPos = worldPos;
|
|
185
|
+
return output;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
189
|
+
// Early alpha test - discard before expensive calculations
|
|
190
|
+
var finalAlpha = material.alpha * material.alphaMultiplier;
|
|
191
|
+
if (material.isOverEyes > 0.5) {
|
|
192
|
+
finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
|
|
193
|
+
}
|
|
194
|
+
if (finalAlpha < 0.001) {
|
|
195
|
+
discard;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let n = normalize(input.normal);
|
|
199
|
+
let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
200
|
+
|
|
201
|
+
var lightAccum = light.ambient * light.ambientColor;
|
|
202
|
+
let numLights = u32(light.lightCount);
|
|
203
|
+
for (var i = 0u; i < numLights; i++) {
|
|
204
|
+
let l = -light.lights[i].direction;
|
|
205
|
+
let nDotL = max(dot(n, l), 0.0);
|
|
206
|
+
let toonUV = vec2f(nDotL, 0.5);
|
|
207
|
+
let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
|
|
208
|
+
let radiance = light.lights[i].color * light.lights[i].intensity;
|
|
209
|
+
lightAccum += toonFactor * radiance * nDotL;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Rim light calculation
|
|
213
|
+
let viewDir = normalize(camera.viewPos - input.worldPos);
|
|
214
|
+
var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
|
|
215
|
+
rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
|
|
216
|
+
let rimLight = material.rimColor * material.rimIntensity * rimFactor;
|
|
217
|
+
|
|
218
|
+
let color = albedo * lightAccum + rimLight;
|
|
219
|
+
|
|
220
|
+
return vec4f(color, finalAlpha);
|
|
221
|
+
}
|
|
217
222
|
`,
|
|
218
223
|
});
|
|
219
224
|
// Create explicit bind group layout for all pipelines using the main shader
|
|
@@ -303,73 +308,73 @@ export class Engine {
|
|
|
303
308
|
});
|
|
304
309
|
const outlineShaderModule = this.device.createShaderModule({
|
|
305
310
|
label: "outline shaders",
|
|
306
|
-
code: /* wgsl */ `
|
|
307
|
-
struct CameraUniforms {
|
|
308
|
-
view: mat4x4f,
|
|
309
|
-
projection: mat4x4f,
|
|
310
|
-
viewPos: vec3f,
|
|
311
|
-
_padding: f32,
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
struct MaterialUniforms {
|
|
315
|
-
edgeColor: vec4f,
|
|
316
|
-
edgeSize: f32,
|
|
317
|
-
isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
|
|
318
|
-
_padding1: f32,
|
|
319
|
-
_padding2: f32,
|
|
320
|
-
};
|
|
321
|
-
|
|
322
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
323
|
-
@group(0) @binding(1) var<uniform> material: MaterialUniforms;
|
|
324
|
-
@group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
|
|
325
|
-
|
|
326
|
-
struct VertexOutput {
|
|
327
|
-
@builtin(position) position: vec4f,
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
@vertex fn vs(
|
|
331
|
-
@location(0) position: vec3f,
|
|
332
|
-
@location(1) normal: vec3f,
|
|
333
|
-
@location(3) joints0: vec4<u32>,
|
|
334
|
-
@location(4) weights0: vec4<f32>
|
|
335
|
-
) -> VertexOutput {
|
|
336
|
-
var output: VertexOutput;
|
|
337
|
-
let pos4 = vec4f(position, 1.0);
|
|
338
|
-
|
|
339
|
-
// Branchless weight normalization (avoids GPU branch divergence)
|
|
340
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
341
|
-
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
342
|
-
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
343
|
-
|
|
344
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
345
|
-
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
346
|
-
for (var i = 0u; i < 4u; i++) {
|
|
347
|
-
let j = joints0[i];
|
|
348
|
-
let w = normalizedWeights[i];
|
|
349
|
-
let m = skinMats[j];
|
|
350
|
-
skinnedPos += (m * pos4) * w;
|
|
351
|
-
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
352
|
-
skinnedNrm += (r3 * normal) * w;
|
|
353
|
-
}
|
|
354
|
-
let worldPos = skinnedPos.xyz;
|
|
355
|
-
let worldNormal = normalize(skinnedNrm);
|
|
356
|
-
|
|
357
|
-
// MMD invert hull: expand vertices outward along normals
|
|
358
|
-
let scaleFactor = 0.01;
|
|
359
|
-
let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
|
|
360
|
-
output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
|
|
361
|
-
return output;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
@fragment fn fs() -> @location(0) vec4f {
|
|
365
|
-
var color = material.edgeColor;
|
|
366
|
-
|
|
367
|
-
if (material.isOverEyes > 0.5) {
|
|
368
|
-
color.a *= 0.5; // Hair outlines over eyes get 50% alpha
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return color;
|
|
372
|
-
}
|
|
311
|
+
code: /* wgsl */ `
|
|
312
|
+
struct CameraUniforms {
|
|
313
|
+
view: mat4x4f,
|
|
314
|
+
projection: mat4x4f,
|
|
315
|
+
viewPos: vec3f,
|
|
316
|
+
_padding: f32,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
struct MaterialUniforms {
|
|
320
|
+
edgeColor: vec4f,
|
|
321
|
+
edgeSize: f32,
|
|
322
|
+
isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
|
|
323
|
+
_padding1: f32,
|
|
324
|
+
_padding2: f32,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
328
|
+
@group(0) @binding(1) var<uniform> material: MaterialUniforms;
|
|
329
|
+
@group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
|
|
330
|
+
|
|
331
|
+
struct VertexOutput {
|
|
332
|
+
@builtin(position) position: vec4f,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
@vertex fn vs(
|
|
336
|
+
@location(0) position: vec3f,
|
|
337
|
+
@location(1) normal: vec3f,
|
|
338
|
+
@location(3) joints0: vec4<u32>,
|
|
339
|
+
@location(4) weights0: vec4<f32>
|
|
340
|
+
) -> VertexOutput {
|
|
341
|
+
var output: VertexOutput;
|
|
342
|
+
let pos4 = vec4f(position, 1.0);
|
|
343
|
+
|
|
344
|
+
// Branchless weight normalization (avoids GPU branch divergence)
|
|
345
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
346
|
+
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
347
|
+
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
348
|
+
|
|
349
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
350
|
+
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
351
|
+
for (var i = 0u; i < 4u; i++) {
|
|
352
|
+
let j = joints0[i];
|
|
353
|
+
let w = normalizedWeights[i];
|
|
354
|
+
let m = skinMats[j];
|
|
355
|
+
skinnedPos += (m * pos4) * w;
|
|
356
|
+
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
357
|
+
skinnedNrm += (r3 * normal) * w;
|
|
358
|
+
}
|
|
359
|
+
let worldPos = skinnedPos.xyz;
|
|
360
|
+
let worldNormal = normalize(skinnedNrm);
|
|
361
|
+
|
|
362
|
+
// MMD invert hull: expand vertices outward along normals
|
|
363
|
+
let scaleFactor = 0.01;
|
|
364
|
+
let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
|
|
365
|
+
output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
|
|
366
|
+
return output;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@fragment fn fs() -> @location(0) vec4f {
|
|
370
|
+
var color = material.edgeColor;
|
|
371
|
+
|
|
372
|
+
if (material.isOverEyes > 0.5) {
|
|
373
|
+
color.a *= 0.5; // Hair outlines over eyes get 50% alpha
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return color;
|
|
377
|
+
}
|
|
373
378
|
`,
|
|
374
379
|
});
|
|
375
380
|
this.outlinePipeline = this.device.createRenderPipeline({
|
|
@@ -573,45 +578,45 @@ export class Engine {
|
|
|
573
578
|
// Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
|
|
574
579
|
const depthOnlyShaderModule = this.device.createShaderModule({
|
|
575
580
|
label: "depth only shader",
|
|
576
|
-
code: /* wgsl */ `
|
|
577
|
-
struct CameraUniforms {
|
|
578
|
-
view: mat4x4f,
|
|
579
|
-
projection: mat4x4f,
|
|
580
|
-
viewPos: vec3f,
|
|
581
|
-
_padding: f32,
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
585
|
-
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
586
|
-
|
|
587
|
-
@vertex fn vs(
|
|
588
|
-
@location(0) position: vec3f,
|
|
589
|
-
@location(1) normal: vec3f,
|
|
590
|
-
@location(3) joints0: vec4<u32>,
|
|
591
|
-
@location(4) weights0: vec4<f32>
|
|
592
|
-
) -> @builtin(position) vec4f {
|
|
593
|
-
let pos4 = vec4f(position, 1.0);
|
|
594
|
-
|
|
595
|
-
// Branchless weight normalization (avoids GPU branch divergence)
|
|
596
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
597
|
-
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
598
|
-
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
599
|
-
|
|
600
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
601
|
-
for (var i = 0u; i < 4u; i++) {
|
|
602
|
-
let j = joints0[i];
|
|
603
|
-
let w = normalizedWeights[i];
|
|
604
|
-
let m = skinMats[j];
|
|
605
|
-
skinnedPos += (m * pos4) * w;
|
|
606
|
-
}
|
|
607
|
-
let worldPos = skinnedPos.xyz;
|
|
608
|
-
let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
609
|
-
return clipPos;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
@fragment fn fs() -> @location(0) vec4f {
|
|
613
|
-
return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
|
|
614
|
-
}
|
|
581
|
+
code: /* wgsl */ `
|
|
582
|
+
struct CameraUniforms {
|
|
583
|
+
view: mat4x4f,
|
|
584
|
+
projection: mat4x4f,
|
|
585
|
+
viewPos: vec3f,
|
|
586
|
+
_padding: f32,
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
590
|
+
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
591
|
+
|
|
592
|
+
@vertex fn vs(
|
|
593
|
+
@location(0) position: vec3f,
|
|
594
|
+
@location(1) normal: vec3f,
|
|
595
|
+
@location(3) joints0: vec4<u32>,
|
|
596
|
+
@location(4) weights0: vec4<f32>
|
|
597
|
+
) -> @builtin(position) vec4f {
|
|
598
|
+
let pos4 = vec4f(position, 1.0);
|
|
599
|
+
|
|
600
|
+
// Branchless weight normalization (avoids GPU branch divergence)
|
|
601
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
602
|
+
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
603
|
+
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
604
|
+
|
|
605
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
606
|
+
for (var i = 0u; i < 4u; i++) {
|
|
607
|
+
let j = joints0[i];
|
|
608
|
+
let w = normalizedWeights[i];
|
|
609
|
+
let m = skinMats[j];
|
|
610
|
+
skinnedPos += (m * pos4) * w;
|
|
611
|
+
}
|
|
612
|
+
let worldPos = skinnedPos.xyz;
|
|
613
|
+
let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
614
|
+
return clipPos;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
@fragment fn fs() -> @location(0) vec4f {
|
|
618
|
+
return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
|
|
619
|
+
}
|
|
615
620
|
`,
|
|
616
621
|
});
|
|
617
622
|
// Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
|
|
@@ -794,30 +799,30 @@ export class Engine {
|
|
|
794
799
|
createSkinMatrixComputePipeline() {
|
|
795
800
|
const computeShader = this.device.createShaderModule({
|
|
796
801
|
label: "skin matrix compute",
|
|
797
|
-
code: /* wgsl */ `
|
|
798
|
-
struct BoneCountUniform {
|
|
799
|
-
count: u32,
|
|
800
|
-
_padding1: u32,
|
|
801
|
-
_padding2: u32,
|
|
802
|
-
_padding3: u32,
|
|
803
|
-
_padding4: vec4<u32>,
|
|
804
|
-
};
|
|
805
|
-
|
|
806
|
-
@group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
|
|
807
|
-
@group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
|
|
808
|
-
@group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
|
|
809
|
-
@group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
|
|
810
|
-
|
|
811
|
-
@compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
|
|
812
|
-
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
|
813
|
-
let boneIndex = globalId.x;
|
|
814
|
-
if (boneIndex >= boneCount.count) {
|
|
815
|
-
return;
|
|
816
|
-
}
|
|
817
|
-
let worldMat = worldMatrices[boneIndex];
|
|
818
|
-
let invBindMat = inverseBindMatrices[boneIndex];
|
|
819
|
-
skinMatrices[boneIndex] = worldMat * invBindMat;
|
|
820
|
-
}
|
|
802
|
+
code: /* wgsl */ `
|
|
803
|
+
struct BoneCountUniform {
|
|
804
|
+
count: u32,
|
|
805
|
+
_padding1: u32,
|
|
806
|
+
_padding2: u32,
|
|
807
|
+
_padding3: u32,
|
|
808
|
+
_padding4: vec4<u32>,
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
@group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
|
|
812
|
+
@group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
|
|
813
|
+
@group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
|
|
814
|
+
@group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
|
|
815
|
+
|
|
816
|
+
@compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
|
|
817
|
+
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
|
818
|
+
let boneIndex = globalId.x;
|
|
819
|
+
if (boneIndex >= boneCount.count) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
let worldMat = worldMatrices[boneIndex];
|
|
823
|
+
let invBindMat = inverseBindMatrices[boneIndex];
|
|
824
|
+
skinMatrices[boneIndex] = worldMat * invBindMat;
|
|
825
|
+
}
|
|
821
826
|
`,
|
|
822
827
|
});
|
|
823
828
|
this.skinMatrixComputePipeline = this.device.createComputePipeline({
|
|
@@ -871,140 +876,140 @@ export class Engine {
|
|
|
871
876
|
// Bloom extraction shader (extracts bright areas)
|
|
872
877
|
const bloomExtractShader = this.device.createShaderModule({
|
|
873
878
|
label: "bloom extract",
|
|
874
|
-
code: /* wgsl */ `
|
|
875
|
-
struct VertexOutput {
|
|
876
|
-
@builtin(position) position: vec4f,
|
|
877
|
-
@location(0) uv: vec2f,
|
|
878
|
-
};
|
|
879
|
-
|
|
880
|
-
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
881
|
-
var output: VertexOutput;
|
|
882
|
-
// Generate fullscreen quad from vertex index
|
|
883
|
-
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
884
|
-
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
885
|
-
output.position = vec4f(x, y, 0.0, 1.0);
|
|
886
|
-
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
887
|
-
return output;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
struct BloomExtractUniforms {
|
|
891
|
-
threshold: f32,
|
|
892
|
-
_padding1: f32,
|
|
893
|
-
_padding2: f32,
|
|
894
|
-
_padding3: f32,
|
|
895
|
-
_padding4: f32,
|
|
896
|
-
_padding5: f32,
|
|
897
|
-
_padding6: f32,
|
|
898
|
-
_padding7: f32,
|
|
899
|
-
};
|
|
900
|
-
|
|
901
|
-
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
902
|
-
@group(0) @binding(1) var inputSampler: sampler;
|
|
903
|
-
@group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
|
|
904
|
-
|
|
905
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
906
|
-
let color = textureSample(inputTexture, inputSampler, input.uv);
|
|
907
|
-
// Extract bright areas above threshold
|
|
908
|
-
let threshold = extractUniforms.threshold;
|
|
909
|
-
let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
|
|
910
|
-
return vec4f(bloom, color.a);
|
|
911
|
-
}
|
|
879
|
+
code: /* wgsl */ `
|
|
880
|
+
struct VertexOutput {
|
|
881
|
+
@builtin(position) position: vec4f,
|
|
882
|
+
@location(0) uv: vec2f,
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
886
|
+
var output: VertexOutput;
|
|
887
|
+
// Generate fullscreen quad from vertex index
|
|
888
|
+
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
889
|
+
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
890
|
+
output.position = vec4f(x, y, 0.0, 1.0);
|
|
891
|
+
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
892
|
+
return output;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
struct BloomExtractUniforms {
|
|
896
|
+
threshold: f32,
|
|
897
|
+
_padding1: f32,
|
|
898
|
+
_padding2: f32,
|
|
899
|
+
_padding3: f32,
|
|
900
|
+
_padding4: f32,
|
|
901
|
+
_padding5: f32,
|
|
902
|
+
_padding6: f32,
|
|
903
|
+
_padding7: f32,
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
907
|
+
@group(0) @binding(1) var inputSampler: sampler;
|
|
908
|
+
@group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
|
|
909
|
+
|
|
910
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
911
|
+
let color = textureSample(inputTexture, inputSampler, input.uv);
|
|
912
|
+
// Extract bright areas above threshold
|
|
913
|
+
let threshold = extractUniforms.threshold;
|
|
914
|
+
let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
|
|
915
|
+
return vec4f(bloom, color.a);
|
|
916
|
+
}
|
|
912
917
|
`,
|
|
913
918
|
});
|
|
914
919
|
// Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
|
|
915
920
|
const bloomBlurShader = this.device.createShaderModule({
|
|
916
921
|
label: "bloom blur",
|
|
917
|
-
code: /* wgsl */ `
|
|
918
|
-
struct VertexOutput {
|
|
919
|
-
@builtin(position) position: vec4f,
|
|
920
|
-
@location(0) uv: vec2f,
|
|
921
|
-
};
|
|
922
|
-
|
|
923
|
-
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
924
|
-
var output: VertexOutput;
|
|
925
|
-
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
926
|
-
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
927
|
-
output.position = vec4f(x, y, 0.0, 1.0);
|
|
928
|
-
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
929
|
-
return output;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
struct BlurUniforms {
|
|
933
|
-
direction: vec2f,
|
|
934
|
-
_padding1: f32,
|
|
935
|
-
_padding2: f32,
|
|
936
|
-
_padding3: f32,
|
|
937
|
-
_padding4: f32,
|
|
938
|
-
_padding5: f32,
|
|
939
|
-
_padding6: f32,
|
|
940
|
-
};
|
|
941
|
-
|
|
942
|
-
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
943
|
-
@group(0) @binding(1) var inputSampler: sampler;
|
|
944
|
-
@group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
|
|
945
|
-
|
|
946
|
-
// 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
|
|
947
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
948
|
-
let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
|
|
949
|
-
|
|
950
|
-
// Bilinear optimization: leverage hardware filtering to sample between pixels
|
|
951
|
-
// Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
|
|
952
|
-
// Optimized 3-tap: combine adjacent samples using weighted offsets
|
|
953
|
-
let weight0 = 0.38774; // Center sample
|
|
954
|
-
let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
|
|
955
|
-
let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
|
|
956
|
-
|
|
957
|
-
var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
|
|
958
|
-
let offsetVec = offset1 * texelSize * blurUniforms.direction;
|
|
959
|
-
result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
|
|
960
|
-
result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
|
|
961
|
-
|
|
962
|
-
return result;
|
|
963
|
-
}
|
|
922
|
+
code: /* wgsl */ `
|
|
923
|
+
struct VertexOutput {
|
|
924
|
+
@builtin(position) position: vec4f,
|
|
925
|
+
@location(0) uv: vec2f,
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
929
|
+
var output: VertexOutput;
|
|
930
|
+
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
931
|
+
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
932
|
+
output.position = vec4f(x, y, 0.0, 1.0);
|
|
933
|
+
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
934
|
+
return output;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
struct BlurUniforms {
|
|
938
|
+
direction: vec2f,
|
|
939
|
+
_padding1: f32,
|
|
940
|
+
_padding2: f32,
|
|
941
|
+
_padding3: f32,
|
|
942
|
+
_padding4: f32,
|
|
943
|
+
_padding5: f32,
|
|
944
|
+
_padding6: f32,
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
948
|
+
@group(0) @binding(1) var inputSampler: sampler;
|
|
949
|
+
@group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
|
|
950
|
+
|
|
951
|
+
// 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
|
|
952
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
953
|
+
let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
|
|
954
|
+
|
|
955
|
+
// Bilinear optimization: leverage hardware filtering to sample between pixels
|
|
956
|
+
// Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
|
|
957
|
+
// Optimized 3-tap: combine adjacent samples using weighted offsets
|
|
958
|
+
let weight0 = 0.38774; // Center sample
|
|
959
|
+
let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
|
|
960
|
+
let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
|
|
961
|
+
|
|
962
|
+
var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
|
|
963
|
+
let offsetVec = offset1 * texelSize * blurUniforms.direction;
|
|
964
|
+
result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
|
|
965
|
+
result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
|
|
966
|
+
|
|
967
|
+
return result;
|
|
968
|
+
}
|
|
964
969
|
`,
|
|
965
970
|
});
|
|
966
971
|
// Bloom composition shader (combines original scene with bloom)
|
|
967
972
|
const bloomComposeShader = this.device.createShaderModule({
|
|
968
973
|
label: "bloom compose",
|
|
969
|
-
code: /* wgsl */ `
|
|
970
|
-
struct VertexOutput {
|
|
971
|
-
@builtin(position) position: vec4f,
|
|
972
|
-
@location(0) uv: vec2f,
|
|
973
|
-
};
|
|
974
|
-
|
|
975
|
-
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
976
|
-
var output: VertexOutput;
|
|
977
|
-
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
978
|
-
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
979
|
-
output.position = vec4f(x, y, 0.0, 1.0);
|
|
980
|
-
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
981
|
-
return output;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
struct BloomComposeUniforms {
|
|
985
|
-
intensity: f32,
|
|
986
|
-
_padding1: f32,
|
|
987
|
-
_padding2: f32,
|
|
988
|
-
_padding3: f32,
|
|
989
|
-
_padding4: f32,
|
|
990
|
-
_padding5: f32,
|
|
991
|
-
_padding6: f32,
|
|
992
|
-
_padding7: f32,
|
|
993
|
-
};
|
|
994
|
-
|
|
995
|
-
@group(0) @binding(0) var sceneTexture: texture_2d<f32>;
|
|
996
|
-
@group(0) @binding(1) var sceneSampler: sampler;
|
|
997
|
-
@group(0) @binding(2) var bloomTexture: texture_2d<f32>;
|
|
998
|
-
@group(0) @binding(3) var bloomSampler: sampler;
|
|
999
|
-
@group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
|
|
1000
|
-
|
|
1001
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
1002
|
-
let scene = textureSample(sceneTexture, sceneSampler, input.uv);
|
|
1003
|
-
let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
|
|
1004
|
-
// Additive blending with intensity control
|
|
1005
|
-
let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
|
|
1006
|
-
return vec4f(result, scene.a);
|
|
1007
|
-
}
|
|
974
|
+
code: /* wgsl */ `
|
|
975
|
+
struct VertexOutput {
|
|
976
|
+
@builtin(position) position: vec4f,
|
|
977
|
+
@location(0) uv: vec2f,
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
981
|
+
var output: VertexOutput;
|
|
982
|
+
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
983
|
+
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
984
|
+
output.position = vec4f(x, y, 0.0, 1.0);
|
|
985
|
+
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
986
|
+
return output;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
struct BloomComposeUniforms {
|
|
990
|
+
intensity: f32,
|
|
991
|
+
_padding1: f32,
|
|
992
|
+
_padding2: f32,
|
|
993
|
+
_padding3: f32,
|
|
994
|
+
_padding4: f32,
|
|
995
|
+
_padding5: f32,
|
|
996
|
+
_padding6: f32,
|
|
997
|
+
_padding7: f32,
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
@group(0) @binding(0) var sceneTexture: texture_2d<f32>;
|
|
1001
|
+
@group(0) @binding(1) var sceneSampler: sampler;
|
|
1002
|
+
@group(0) @binding(2) var bloomTexture: texture_2d<f32>;
|
|
1003
|
+
@group(0) @binding(3) var bloomSampler: sampler;
|
|
1004
|
+
@group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
|
|
1005
|
+
|
|
1006
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
1007
|
+
let scene = textureSample(sceneTexture, sceneSampler, input.uv);
|
|
1008
|
+
let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
|
|
1009
|
+
// Additive blending with intensity control
|
|
1010
|
+
let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
|
|
1011
|
+
return vec4f(result, scene.a);
|
|
1012
|
+
}
|
|
1008
1013
|
`,
|
|
1009
1014
|
});
|
|
1010
1015
|
// Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
|
|
@@ -1238,6 +1243,7 @@ export class Engine {
|
|
|
1238
1243
|
});
|
|
1239
1244
|
this.lightCount = 0;
|
|
1240
1245
|
this.setAmbient(this.ambient);
|
|
1246
|
+
this.setAmbientColor(this.ambientColor);
|
|
1241
1247
|
this.addLight(new Vec3(-0.5, -0.8, 0.5).normalize(), new Vec3(1.0, 0.95, 0.9), 0.02);
|
|
1242
1248
|
this.addLight(new Vec3(0.7, -0.5, 0.3).normalize(), new Vec3(0.8, 0.85, 1.0), 0.015);
|
|
1243
1249
|
this.addLight(new Vec3(0.3, -0.5, -1.0).normalize(), new Vec3(0.9, 0.9, 1.0), 0.01);
|
|
@@ -1247,7 +1253,7 @@ export class Engine {
|
|
|
1247
1253
|
if (this.lightCount >= 4)
|
|
1248
1254
|
return false;
|
|
1249
1255
|
const normalized = direction.normalize();
|
|
1250
|
-
const baseIndex =
|
|
1256
|
+
const baseIndex = 12 + this.lightCount * 8;
|
|
1251
1257
|
this.lightData[baseIndex] = normalized.x;
|
|
1252
1258
|
this.lightData[baseIndex + 1] = normalized.y;
|
|
1253
1259
|
this.lightData[baseIndex + 2] = normalized.z;
|
|
@@ -1257,22 +1263,47 @@ export class Engine {
|
|
|
1257
1263
|
this.lightData[baseIndex + 6] = color.z;
|
|
1258
1264
|
this.lightData[baseIndex + 7] = intensity;
|
|
1259
1265
|
this.lightCount++;
|
|
1260
|
-
|
|
1266
|
+
// lightCount: f32 at offset 28 (index 7)
|
|
1267
|
+
// Layout: ambient (0), padding (1-3), ambientColor (4-6, padding 7), lightCount (8), _padding1 (9), _padding2 (10), lights start at 12
|
|
1268
|
+
this.lightData[8] = this.lightCount;
|
|
1261
1269
|
return true;
|
|
1262
1270
|
}
|
|
1263
1271
|
setAmbient(intensity) {
|
|
1272
|
+
// ambient: f32 at offset 0 (index 0)
|
|
1264
1273
|
this.lightData[0] = intensity;
|
|
1265
1274
|
}
|
|
1275
|
+
setAmbientColor(color) {
|
|
1276
|
+
this.lightData[4] = color.x;
|
|
1277
|
+
this.lightData[5] = color.y;
|
|
1278
|
+
this.lightData[6] = color.z;
|
|
1279
|
+
// Index 7 is padding for vec3f alignment (must be 0)
|
|
1280
|
+
this.lightData[7] = 0.0;
|
|
1281
|
+
}
|
|
1266
1282
|
async loadAnimation(url) {
|
|
1267
1283
|
const frames = await VMDLoader.load(url);
|
|
1268
1284
|
this.animationFrames = frames;
|
|
1269
1285
|
this.hasAnimation = true;
|
|
1270
1286
|
}
|
|
1271
|
-
playAnimation() {
|
|
1287
|
+
playAnimation(options) {
|
|
1272
1288
|
if (this.animationFrames.length === 0)
|
|
1273
1289
|
return;
|
|
1274
1290
|
this.stopAnimation();
|
|
1291
|
+
this.stopBreathing();
|
|
1275
1292
|
this.playingAnimation = true;
|
|
1293
|
+
// Enable breathing if breathBones is provided
|
|
1294
|
+
const enableBreath = options?.breathBones !== undefined && options.breathBones !== null;
|
|
1295
|
+
let breathBones = [];
|
|
1296
|
+
let breathRotationRanges = undefined;
|
|
1297
|
+
if (enableBreath && options.breathBones) {
|
|
1298
|
+
if (Array.isArray(options.breathBones)) {
|
|
1299
|
+
breathBones = options.breathBones;
|
|
1300
|
+
}
|
|
1301
|
+
else {
|
|
1302
|
+
breathBones = Object.keys(options.breathBones);
|
|
1303
|
+
breathRotationRanges = options.breathBones;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const breathDuration = options?.breathDuration ?? 4000;
|
|
1276
1307
|
const allBoneKeyFrames = [];
|
|
1277
1308
|
for (const keyFrame of this.animationFrames) {
|
|
1278
1309
|
for (const boneFrame of keyFrame.boneFrames) {
|
|
@@ -1360,6 +1391,40 @@ export class Engine {
|
|
|
1360
1391
|
}
|
|
1361
1392
|
}
|
|
1362
1393
|
}
|
|
1394
|
+
// Setup breathing animation if enabled
|
|
1395
|
+
if (enableBreath && this.currentModel) {
|
|
1396
|
+
// Find the last frame time
|
|
1397
|
+
let maxTime = 0;
|
|
1398
|
+
for (const keyFrame of this.animationFrames) {
|
|
1399
|
+
if (keyFrame.time > maxTime) {
|
|
1400
|
+
maxTime = keyFrame.time;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
// Get last frame rotations directly from animation data for breathing bones
|
|
1404
|
+
const lastFrameRotations = new Map();
|
|
1405
|
+
for (const bone of breathBones) {
|
|
1406
|
+
const keyFrames = boneKeyFramesByBone.get(bone);
|
|
1407
|
+
if (keyFrames && keyFrames.length > 0) {
|
|
1408
|
+
// Find the rotation at the last frame time (closest keyframe <= maxTime)
|
|
1409
|
+
let lastRotation = null;
|
|
1410
|
+
for (let i = keyFrames.length - 1; i >= 0; i--) {
|
|
1411
|
+
if (keyFrames[i].time <= maxTime) {
|
|
1412
|
+
lastRotation = keyFrames[i].rotation;
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
if (lastRotation) {
|
|
1417
|
+
lastFrameRotations.set(bone, lastRotation);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
// Start breathing after animation completes
|
|
1422
|
+
// Use the last frame rotations directly from animation data (no need to capture from model)
|
|
1423
|
+
const animationEndTime = maxTime * 1000 + 200; // Small buffer for final tweens to complete
|
|
1424
|
+
this.breathingTimeout = window.setTimeout(() => {
|
|
1425
|
+
this.startBreathing(breathBones, lastFrameRotations, breathRotationRanges, breathDuration);
|
|
1426
|
+
}, animationEndTime);
|
|
1427
|
+
}
|
|
1363
1428
|
}
|
|
1364
1429
|
stopAnimation() {
|
|
1365
1430
|
for (const timeoutId of this.animationTimeouts) {
|
|
@@ -1368,6 +1433,54 @@ export class Engine {
|
|
|
1368
1433
|
this.animationTimeouts = [];
|
|
1369
1434
|
this.playingAnimation = false;
|
|
1370
1435
|
}
|
|
1436
|
+
stopBreathing() {
|
|
1437
|
+
if (this.breathingTimeout !== null) {
|
|
1438
|
+
clearTimeout(this.breathingTimeout);
|
|
1439
|
+
this.breathingTimeout = null;
|
|
1440
|
+
}
|
|
1441
|
+
this.breathingBaseRotations.clear();
|
|
1442
|
+
}
|
|
1443
|
+
startBreathing(bones, baseRotations, rotationRanges, durationMs = 4000) {
|
|
1444
|
+
if (!this.currentModel)
|
|
1445
|
+
return;
|
|
1446
|
+
// Store base rotations directly from last frame of animation data
|
|
1447
|
+
// These are the exact rotations from the animation - use them as-is
|
|
1448
|
+
for (const bone of bones) {
|
|
1449
|
+
const baseRot = baseRotations.get(bone);
|
|
1450
|
+
if (baseRot) {
|
|
1451
|
+
this.breathingBaseRotations.set(bone, baseRot);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const halfCycleMs = durationMs / 2;
|
|
1455
|
+
const defaultRotation = 0.02; // Default rotation range if not specified per bone
|
|
1456
|
+
// Start breathing cycle - oscillate around exact base rotation (final pose)
|
|
1457
|
+
// Each bone can have its own rotation range, or use default
|
|
1458
|
+
const animate = (isInhale) => {
|
|
1459
|
+
if (!this.currentModel)
|
|
1460
|
+
return;
|
|
1461
|
+
const breathingBoneNames = [];
|
|
1462
|
+
const breathingQuats = [];
|
|
1463
|
+
for (const bone of bones) {
|
|
1464
|
+
const baseRot = this.breathingBaseRotations.get(bone);
|
|
1465
|
+
if (!baseRot)
|
|
1466
|
+
continue;
|
|
1467
|
+
// Get rotation range for this bone (per-bone or default)
|
|
1468
|
+
const rotation = rotationRanges?.[bone] ?? defaultRotation;
|
|
1469
|
+
// Oscillate around base rotation with the bone's rotation range
|
|
1470
|
+
// isInhale: base * rotation, exhale: base * (-rotation)
|
|
1471
|
+
const oscillationRot = Quat.fromEuler(isInhale ? rotation : -rotation, 0, 0);
|
|
1472
|
+
const finalRot = baseRot.multiply(oscillationRot);
|
|
1473
|
+
breathingBoneNames.push(bone);
|
|
1474
|
+
breathingQuats.push(finalRot);
|
|
1475
|
+
}
|
|
1476
|
+
if (breathingBoneNames.length > 0) {
|
|
1477
|
+
this.rotateBones(breathingBoneNames, breathingQuats, halfCycleMs);
|
|
1478
|
+
}
|
|
1479
|
+
this.breathingTimeout = window.setTimeout(() => animate(!isInhale), halfCycleMs);
|
|
1480
|
+
};
|
|
1481
|
+
// Start breathing from exhale position (closer to base) to minimize initial movement
|
|
1482
|
+
animate(false);
|
|
1483
|
+
}
|
|
1371
1484
|
getStats() {
|
|
1372
1485
|
return { ...this.stats };
|
|
1373
1486
|
}
|
|
@@ -1392,6 +1505,7 @@ export class Engine {
|
|
|
1392
1505
|
dispose() {
|
|
1393
1506
|
this.stopRenderLoop();
|
|
1394
1507
|
this.stopAnimation();
|
|
1508
|
+
this.stopBreathing();
|
|
1395
1509
|
if (this.camera)
|
|
1396
1510
|
this.camera.detachControl();
|
|
1397
1511
|
if (this.resizeObserver) {
|