reze-engine 0.1.4 → 0.1.6
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 +99 -99
- package/dist/engine.d.ts +15 -11
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +931 -295
- package/dist/model.d.ts +3 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +26 -2
- package/package.json +1 -1
- package/src/camera.ts +358 -358
- package/src/engine.ts +1826 -1158
- package/src/math.ts +546 -546
- package/src/model.ts +421 -418
- package/src/physics.ts +680 -680
- package/src/pmx-loader.ts +1060 -1031
package/dist/engine.js
CHANGED
|
@@ -24,20 +24,18 @@ export class Engine {
|
|
|
24
24
|
this.stats = {
|
|
25
25
|
fps: 0,
|
|
26
26
|
frameTime: 0,
|
|
27
|
-
memoryUsed: 0,
|
|
28
|
-
vertices: 0,
|
|
29
|
-
drawCalls: 0,
|
|
30
|
-
triangles: 0,
|
|
31
|
-
materials: 0,
|
|
32
|
-
textures: 0,
|
|
33
|
-
textureMemory: 0,
|
|
34
|
-
bufferMemory: 0,
|
|
35
27
|
gpuMemory: 0,
|
|
36
28
|
};
|
|
37
29
|
this.animationFrameId = null;
|
|
38
30
|
this.renderLoopCallback = null;
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
31
|
+
this.opaqueNonEyeNonHairDraws = [];
|
|
32
|
+
this.eyeDraws = [];
|
|
33
|
+
this.hairDraws = [];
|
|
34
|
+
this.transparentNonEyeNonHairDraws = [];
|
|
35
|
+
this.opaqueNonEyeNonHairOutlineDraws = [];
|
|
36
|
+
this.eyeOutlineDraws = [];
|
|
37
|
+
this.hairOutlineDraws = [];
|
|
38
|
+
this.transparentNonEyeNonHairOutlineDraws = [];
|
|
41
39
|
this.canvas = canvas;
|
|
42
40
|
}
|
|
43
41
|
// Step 1: Get WebGPU device and context
|
|
@@ -74,118 +72,251 @@ export class Engine {
|
|
|
74
72
|
});
|
|
75
73
|
const shaderModule = this.device.createShaderModule({
|
|
76
74
|
label: "model shaders",
|
|
77
|
-
code: /* wgsl */ `
|
|
78
|
-
struct CameraUniforms {
|
|
79
|
-
view: mat4x4f,
|
|
80
|
-
projection: mat4x4f,
|
|
81
|
-
viewPos: vec3f,
|
|
82
|
-
_padding: f32,
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
struct Light {
|
|
86
|
-
direction: vec3f,
|
|
87
|
-
_padding1: f32,
|
|
88
|
-
color: vec3f,
|
|
89
|
-
intensity: f32,
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
struct LightUniforms {
|
|
93
|
-
ambient: f32,
|
|
94
|
-
lightCount: f32,
|
|
95
|
-
_padding1: f32,
|
|
96
|
-
_padding2: f32,
|
|
97
|
-
lights: array<Light, 4>,
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
struct MaterialUniforms {
|
|
101
|
-
alpha: f32,
|
|
102
|
-
_padding1: f32,
|
|
103
|
-
_padding2: f32,
|
|
104
|
-
_padding3: f32,
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
struct VertexOutput {
|
|
108
|
-
@builtin(position) position: vec4f,
|
|
109
|
-
@location(0) normal: vec3f,
|
|
110
|
-
@location(1) uv: vec2f,
|
|
111
|
-
@location(2) worldPos: vec3f,
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
115
|
-
@group(0) @binding(1) var<uniform> light: LightUniforms;
|
|
116
|
-
@group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
|
|
117
|
-
@group(0) @binding(3) var diffuseSampler: sampler;
|
|
118
|
-
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
119
|
-
@group(0) @binding(5) var toonTexture: texture_2d<f32>;
|
|
120
|
-
@group(0) @binding(6) var toonSampler: sampler;
|
|
121
|
-
@group(0) @binding(7) var<uniform> material: MaterialUniforms;
|
|
122
|
-
|
|
123
|
-
@vertex fn vs(
|
|
124
|
-
@location(0) position: vec3f,
|
|
125
|
-
@location(1) normal: vec3f,
|
|
126
|
-
@location(2) uv: vec2f,
|
|
127
|
-
@location(3) joints0: vec4<u32>,
|
|
128
|
-
@location(4) weights0: vec4<f32>
|
|
129
|
-
) -> VertexOutput {
|
|
130
|
-
var output: VertexOutput;
|
|
131
|
-
let pos4 = vec4f(position, 1.0);
|
|
132
|
-
|
|
133
|
-
// Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
|
|
134
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
135
|
-
var normalizedWeights: vec4f;
|
|
136
|
-
if (weightSum > 0.0001) {
|
|
137
|
-
normalizedWeights = weights0 / weightSum;
|
|
138
|
-
} else {
|
|
139
|
-
normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
143
|
-
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
144
|
-
for (var i = 0u; i < 4u; i++) {
|
|
145
|
-
let j = joints0[i];
|
|
146
|
-
let w = normalizedWeights[i];
|
|
147
|
-
let m = skinMats[j];
|
|
148
|
-
skinnedPos += (m * pos4) * w;
|
|
149
|
-
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
150
|
-
skinnedNrm += (r3 * normal) * w;
|
|
151
|
-
}
|
|
152
|
-
let worldPos = skinnedPos.xyz;
|
|
153
|
-
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
154
|
-
output.normal = normalize(skinnedNrm);
|
|
155
|
-
output.uv = uv;
|
|
156
|
-
output.worldPos = worldPos;
|
|
157
|
-
return output;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
161
|
-
let n = normalize(input.normal);
|
|
162
|
-
let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
163
|
-
|
|
164
|
-
var lightAccum = vec3f(light.ambient);
|
|
165
|
-
let numLights = u32(light.lightCount);
|
|
166
|
-
for (var i = 0u; i < numLights; i++) {
|
|
167
|
-
let l = -light.lights[i].direction;
|
|
168
|
-
let nDotL = max(dot(n, l), 0.0);
|
|
169
|
-
let toonUV = vec2f(nDotL, 0.5);
|
|
170
|
-
let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
|
|
171
|
-
let radiance = light.lights[i].color * light.lights[i].intensity;
|
|
172
|
-
lightAccum += toonFactor * radiance * nDotL;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
let color = albedo * lightAccum;
|
|
176
|
-
let finalAlpha = material.alpha;
|
|
177
|
-
if (finalAlpha < 0.001) {
|
|
178
|
-
discard;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
|
|
182
|
-
}
|
|
75
|
+
code: /* wgsl */ `
|
|
76
|
+
struct CameraUniforms {
|
|
77
|
+
view: mat4x4f,
|
|
78
|
+
projection: mat4x4f,
|
|
79
|
+
viewPos: vec3f,
|
|
80
|
+
_padding: f32,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
struct Light {
|
|
84
|
+
direction: vec3f,
|
|
85
|
+
_padding1: f32,
|
|
86
|
+
color: vec3f,
|
|
87
|
+
intensity: f32,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
struct LightUniforms {
|
|
91
|
+
ambient: f32,
|
|
92
|
+
lightCount: f32,
|
|
93
|
+
_padding1: f32,
|
|
94
|
+
_padding2: f32,
|
|
95
|
+
lights: array<Light, 4>,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
struct MaterialUniforms {
|
|
99
|
+
alpha: f32,
|
|
100
|
+
_padding1: f32,
|
|
101
|
+
_padding2: f32,
|
|
102
|
+
_padding3: f32,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
struct VertexOutput {
|
|
106
|
+
@builtin(position) position: vec4f,
|
|
107
|
+
@location(0) normal: vec3f,
|
|
108
|
+
@location(1) uv: vec2f,
|
|
109
|
+
@location(2) worldPos: vec3f,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
113
|
+
@group(0) @binding(1) var<uniform> light: LightUniforms;
|
|
114
|
+
@group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
|
|
115
|
+
@group(0) @binding(3) var diffuseSampler: sampler;
|
|
116
|
+
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
117
|
+
@group(0) @binding(5) var toonTexture: texture_2d<f32>;
|
|
118
|
+
@group(0) @binding(6) var toonSampler: sampler;
|
|
119
|
+
@group(0) @binding(7) var<uniform> material: MaterialUniforms;
|
|
120
|
+
|
|
121
|
+
@vertex fn vs(
|
|
122
|
+
@location(0) position: vec3f,
|
|
123
|
+
@location(1) normal: vec3f,
|
|
124
|
+
@location(2) uv: vec2f,
|
|
125
|
+
@location(3) joints0: vec4<u32>,
|
|
126
|
+
@location(4) weights0: vec4<f32>
|
|
127
|
+
) -> VertexOutput {
|
|
128
|
+
var output: VertexOutput;
|
|
129
|
+
let pos4 = vec4f(position, 1.0);
|
|
130
|
+
|
|
131
|
+
// Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
|
|
132
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
133
|
+
var normalizedWeights: vec4f;
|
|
134
|
+
if (weightSum > 0.0001) {
|
|
135
|
+
normalizedWeights = weights0 / weightSum;
|
|
136
|
+
} else {
|
|
137
|
+
normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
141
|
+
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
142
|
+
for (var i = 0u; i < 4u; i++) {
|
|
143
|
+
let j = joints0[i];
|
|
144
|
+
let w = normalizedWeights[i];
|
|
145
|
+
let m = skinMats[j];
|
|
146
|
+
skinnedPos += (m * pos4) * w;
|
|
147
|
+
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
148
|
+
skinnedNrm += (r3 * normal) * w;
|
|
149
|
+
}
|
|
150
|
+
let worldPos = skinnedPos.xyz;
|
|
151
|
+
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
152
|
+
output.normal = normalize(skinnedNrm);
|
|
153
|
+
output.uv = uv;
|
|
154
|
+
output.worldPos = worldPos;
|
|
155
|
+
return output;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
159
|
+
let n = normalize(input.normal);
|
|
160
|
+
let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
161
|
+
|
|
162
|
+
var lightAccum = vec3f(light.ambient);
|
|
163
|
+
let numLights = u32(light.lightCount);
|
|
164
|
+
for (var i = 0u; i < numLights; i++) {
|
|
165
|
+
let l = -light.lights[i].direction;
|
|
166
|
+
let nDotL = max(dot(n, l), 0.0);
|
|
167
|
+
let toonUV = vec2f(nDotL, 0.5);
|
|
168
|
+
let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
|
|
169
|
+
let radiance = light.lights[i].color * light.lights[i].intensity;
|
|
170
|
+
lightAccum += toonFactor * radiance * nDotL;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let color = albedo * lightAccum;
|
|
174
|
+
let finalAlpha = material.alpha;
|
|
175
|
+
if (finalAlpha < 0.001) {
|
|
176
|
+
discard;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
|
|
180
|
+
}
|
|
183
181
|
`,
|
|
184
182
|
});
|
|
183
|
+
// Create a separate shader for hair-over-eyes that outputs pre-multiplied color for darkening effect
|
|
184
|
+
const hairMultiplyShaderModule = this.device.createShaderModule({
|
|
185
|
+
label: "hair multiply shaders",
|
|
186
|
+
code: /* wgsl */ `
|
|
187
|
+
struct CameraUniforms {
|
|
188
|
+
view: mat4x4f,
|
|
189
|
+
projection: mat4x4f,
|
|
190
|
+
viewPos: vec3f,
|
|
191
|
+
_padding: f32,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
struct Light {
|
|
195
|
+
direction: vec3f,
|
|
196
|
+
_padding1: f32,
|
|
197
|
+
color: vec3f,
|
|
198
|
+
intensity: f32,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
struct LightUniforms {
|
|
202
|
+
ambient: f32,
|
|
203
|
+
lightCount: f32,
|
|
204
|
+
_padding1: f32,
|
|
205
|
+
_padding2: f32,
|
|
206
|
+
lights: array<Light, 4>,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
struct MaterialUniforms {
|
|
210
|
+
alpha: f32,
|
|
211
|
+
_padding1: f32,
|
|
212
|
+
_padding2: f32,
|
|
213
|
+
_padding3: f32,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
struct VertexOutput {
|
|
217
|
+
@builtin(position) position: vec4f,
|
|
218
|
+
@location(0) normal: vec3f,
|
|
219
|
+
@location(1) uv: vec2f,
|
|
220
|
+
@location(2) worldPos: vec3f,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
224
|
+
@group(0) @binding(1) var<uniform> light: LightUniforms;
|
|
225
|
+
@group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
|
|
226
|
+
@group(0) @binding(3) var diffuseSampler: sampler;
|
|
227
|
+
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
228
|
+
@group(0) @binding(5) var toonTexture: texture_2d<f32>;
|
|
229
|
+
@group(0) @binding(6) var toonSampler: sampler;
|
|
230
|
+
@group(0) @binding(7) var<uniform> material: MaterialUniforms;
|
|
231
|
+
|
|
232
|
+
@vertex fn vs(
|
|
233
|
+
@location(0) position: vec3f,
|
|
234
|
+
@location(1) normal: vec3f,
|
|
235
|
+
@location(2) uv: vec2f,
|
|
236
|
+
@location(3) joints0: vec4<u32>,
|
|
237
|
+
@location(4) weights0: vec4<f32>
|
|
238
|
+
) -> VertexOutput {
|
|
239
|
+
var output: VertexOutput;
|
|
240
|
+
let pos4 = vec4f(position, 1.0);
|
|
241
|
+
|
|
242
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
243
|
+
var normalizedWeights: vec4f;
|
|
244
|
+
if (weightSum > 0.0001) {
|
|
245
|
+
normalizedWeights = weights0 / weightSum;
|
|
246
|
+
} else {
|
|
247
|
+
normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
251
|
+
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
252
|
+
for (var i = 0u; i < 4u; i++) {
|
|
253
|
+
let j = joints0[i];
|
|
254
|
+
let w = normalizedWeights[i];
|
|
255
|
+
let m = skinMats[j];
|
|
256
|
+
skinnedPos += (m * pos4) * w;
|
|
257
|
+
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
258
|
+
skinnedNrm += (r3 * normal) * w;
|
|
259
|
+
}
|
|
260
|
+
let worldPos = skinnedPos.xyz;
|
|
261
|
+
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
262
|
+
output.normal = normalize(skinnedNrm);
|
|
263
|
+
output.uv = uv;
|
|
264
|
+
output.worldPos = worldPos;
|
|
265
|
+
return output;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
269
|
+
let n = normalize(input.normal);
|
|
270
|
+
let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
271
|
+
|
|
272
|
+
var lightAccum = vec3f(light.ambient);
|
|
273
|
+
let numLights = u32(light.lightCount);
|
|
274
|
+
for (var i = 0u; i < numLights; i++) {
|
|
275
|
+
let l = -light.lights[i].direction;
|
|
276
|
+
let nDotL = max(dot(n, l), 0.0);
|
|
277
|
+
let toonUV = vec2f(nDotL, 0.5);
|
|
278
|
+
let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
|
|
279
|
+
let radiance = light.lights[i].color * light.lights[i].intensity;
|
|
280
|
+
lightAccum += toonFactor * radiance * nDotL;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let color = albedo * lightAccum;
|
|
284
|
+
let finalAlpha = material.alpha;
|
|
285
|
+
if (finalAlpha < 0.001) {
|
|
286
|
+
discard;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// For hair-over-eyes effect: simple half-transparent overlay
|
|
290
|
+
// Use 60% opacity to create a semi-transparent hair color overlay
|
|
291
|
+
let overlayAlpha = finalAlpha * 0.6;
|
|
292
|
+
|
|
293
|
+
return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), overlayAlpha);
|
|
294
|
+
}
|
|
295
|
+
`,
|
|
296
|
+
});
|
|
297
|
+
// Create explicit bind group layout for all pipelines using the main shader
|
|
298
|
+
// This ensures compatibility across all pipelines (main, eye, hair multiply, hair opaque)
|
|
299
|
+
this.hairBindGroupLayout = this.device.createBindGroupLayout({
|
|
300
|
+
label: "shared material bind group layout",
|
|
301
|
+
entries: [
|
|
302
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
|
|
303
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
|
|
304
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
|
|
305
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
|
|
306
|
+
{ binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
|
|
307
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // toonTexture
|
|
308
|
+
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // toonSampler
|
|
309
|
+
{ binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
|
|
310
|
+
],
|
|
311
|
+
});
|
|
312
|
+
const sharedPipelineLayout = this.device.createPipelineLayout({
|
|
313
|
+
label: "shared pipeline layout",
|
|
314
|
+
bindGroupLayouts: [this.hairBindGroupLayout],
|
|
315
|
+
});
|
|
185
316
|
// Single pipeline for all materials with alpha blending
|
|
186
317
|
this.pipeline = this.device.createRenderPipeline({
|
|
187
318
|
label: "model pipeline",
|
|
188
|
-
layout:
|
|
319
|
+
layout: sharedPipelineLayout,
|
|
189
320
|
vertex: {
|
|
190
321
|
module: shaderModule,
|
|
191
322
|
buffers: [
|
|
@@ -229,7 +360,7 @@ export class Engine {
|
|
|
229
360
|
},
|
|
230
361
|
primitive: { cullMode: "none" },
|
|
231
362
|
depthStencil: {
|
|
232
|
-
format: "depth24plus",
|
|
363
|
+
format: "depth24plus-stencil8",
|
|
233
364
|
depthWriteEnabled: true,
|
|
234
365
|
depthCompare: "less",
|
|
235
366
|
},
|
|
@@ -237,79 +368,92 @@ export class Engine {
|
|
|
237
368
|
count: this.sampleCount,
|
|
238
369
|
},
|
|
239
370
|
});
|
|
371
|
+
// Create bind group layout for outline pipelines
|
|
372
|
+
this.outlineBindGroupLayout = this.device.createBindGroupLayout({
|
|
373
|
+
label: "outline bind group layout",
|
|
374
|
+
entries: [
|
|
375
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
|
|
376
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
|
|
377
|
+
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
const outlinePipelineLayout = this.device.createPipelineLayout({
|
|
381
|
+
label: "outline pipeline layout",
|
|
382
|
+
bindGroupLayouts: [this.outlineBindGroupLayout],
|
|
383
|
+
});
|
|
240
384
|
const outlineShaderModule = this.device.createShaderModule({
|
|
241
385
|
label: "outline shaders",
|
|
242
|
-
code: /* wgsl */ `
|
|
243
|
-
struct CameraUniforms {
|
|
244
|
-
view: mat4x4f,
|
|
245
|
-
projection: mat4x4f,
|
|
246
|
-
viewPos: vec3f,
|
|
247
|
-
_padding: f32,
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
struct MaterialUniforms {
|
|
251
|
-
edgeColor: vec4f,
|
|
252
|
-
edgeSize: f32,
|
|
253
|
-
_padding1: f32,
|
|
254
|
-
_padding2: f32,
|
|
255
|
-
_padding3: f32,
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
259
|
-
@group(0) @binding(1) var<uniform> material: MaterialUniforms;
|
|
260
|
-
@group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
|
|
261
|
-
|
|
262
|
-
struct VertexOutput {
|
|
263
|
-
@builtin(position) position: vec4f,
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
@vertex fn vs(
|
|
267
|
-
@location(0) position: vec3f,
|
|
268
|
-
@location(1) normal: vec3f,
|
|
269
|
-
@location(2) uv: vec2f,
|
|
270
|
-
@location(3) joints0: vec4<u32>,
|
|
271
|
-
@location(4) weights0: vec4<f32>
|
|
272
|
-
) -> VertexOutput {
|
|
273
|
-
var output: VertexOutput;
|
|
274
|
-
let pos4 = vec4f(position, 1.0);
|
|
275
|
-
|
|
276
|
-
// Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
|
|
277
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
278
|
-
var normalizedWeights: vec4f;
|
|
279
|
-
if (weightSum > 0.0001) {
|
|
280
|
-
normalizedWeights = weights0 / weightSum;
|
|
281
|
-
} else {
|
|
282
|
-
normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
286
|
-
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
287
|
-
for (var i = 0u; i < 4u; i++) {
|
|
288
|
-
let j = joints0[i];
|
|
289
|
-
let w = normalizedWeights[i];
|
|
290
|
-
let m = skinMats[j];
|
|
291
|
-
skinnedPos += (m * pos4) * w;
|
|
292
|
-
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
293
|
-
skinnedNrm += (r3 * normal) * w;
|
|
294
|
-
}
|
|
295
|
-
let worldPos = skinnedPos.xyz;
|
|
296
|
-
let worldNormal = normalize(skinnedNrm);
|
|
297
|
-
|
|
298
|
-
// MMD invert hull: expand vertices outward along normals
|
|
299
|
-
let scaleFactor = 0.01;
|
|
300
|
-
let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
|
|
301
|
-
output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
|
|
302
|
-
return output;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
@fragment fn fs() -> @location(0) vec4f {
|
|
306
|
-
return material.edgeColor;
|
|
307
|
-
}
|
|
386
|
+
code: /* wgsl */ `
|
|
387
|
+
struct CameraUniforms {
|
|
388
|
+
view: mat4x4f,
|
|
389
|
+
projection: mat4x4f,
|
|
390
|
+
viewPos: vec3f,
|
|
391
|
+
_padding: f32,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
struct MaterialUniforms {
|
|
395
|
+
edgeColor: vec4f,
|
|
396
|
+
edgeSize: f32,
|
|
397
|
+
_padding1: f32,
|
|
398
|
+
_padding2: f32,
|
|
399
|
+
_padding3: f32,
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
403
|
+
@group(0) @binding(1) var<uniform> material: MaterialUniforms;
|
|
404
|
+
@group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
|
|
405
|
+
|
|
406
|
+
struct VertexOutput {
|
|
407
|
+
@builtin(position) position: vec4f,
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
@vertex fn vs(
|
|
411
|
+
@location(0) position: vec3f,
|
|
412
|
+
@location(1) normal: vec3f,
|
|
413
|
+
@location(2) uv: vec2f,
|
|
414
|
+
@location(3) joints0: vec4<u32>,
|
|
415
|
+
@location(4) weights0: vec4<f32>
|
|
416
|
+
) -> VertexOutput {
|
|
417
|
+
var output: VertexOutput;
|
|
418
|
+
let pos4 = vec4f(position, 1.0);
|
|
419
|
+
|
|
420
|
+
// Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
|
|
421
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
422
|
+
var normalizedWeights: vec4f;
|
|
423
|
+
if (weightSum > 0.0001) {
|
|
424
|
+
normalizedWeights = weights0 / weightSum;
|
|
425
|
+
} else {
|
|
426
|
+
normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
430
|
+
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
431
|
+
for (var i = 0u; i < 4u; i++) {
|
|
432
|
+
let j = joints0[i];
|
|
433
|
+
let w = normalizedWeights[i];
|
|
434
|
+
let m = skinMats[j];
|
|
435
|
+
skinnedPos += (m * pos4) * w;
|
|
436
|
+
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
437
|
+
skinnedNrm += (r3 * normal) * w;
|
|
438
|
+
}
|
|
439
|
+
let worldPos = skinnedPos.xyz;
|
|
440
|
+
let worldNormal = normalize(skinnedNrm);
|
|
441
|
+
|
|
442
|
+
// MMD invert hull: expand vertices outward along normals
|
|
443
|
+
let scaleFactor = 0.01;
|
|
444
|
+
let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
|
|
445
|
+
output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
|
|
446
|
+
return output;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
@fragment fn fs() -> @location(0) vec4f {
|
|
450
|
+
return material.edgeColor;
|
|
451
|
+
}
|
|
308
452
|
`,
|
|
309
453
|
});
|
|
310
454
|
this.outlinePipeline = this.device.createRenderPipeline({
|
|
311
455
|
label: "outline pipeline",
|
|
312
|
-
layout:
|
|
456
|
+
layout: outlinePipelineLayout,
|
|
313
457
|
vertex: {
|
|
314
458
|
module: outlineShaderModule,
|
|
315
459
|
buffers: [
|
|
@@ -367,7 +511,7 @@ export class Engine {
|
|
|
367
511
|
cullMode: "back",
|
|
368
512
|
},
|
|
369
513
|
depthStencil: {
|
|
370
|
-
format: "depth24plus",
|
|
514
|
+
format: "depth24plus-stencil8",
|
|
371
515
|
depthWriteEnabled: true,
|
|
372
516
|
depthCompare: "less",
|
|
373
517
|
},
|
|
@@ -375,36 +519,400 @@ export class Engine {
|
|
|
375
519
|
count: this.sampleCount,
|
|
376
520
|
},
|
|
377
521
|
});
|
|
522
|
+
// Hair outline pipeline: draws hair outlines over non-eyes (stencil != 1)
|
|
523
|
+
// Drawn after hair geometry, so depth testing ensures outlines only appear where hair exists
|
|
524
|
+
this.hairOutlinePipeline = this.device.createRenderPipeline({
|
|
525
|
+
label: "hair outline pipeline",
|
|
526
|
+
layout: outlinePipelineLayout,
|
|
527
|
+
vertex: {
|
|
528
|
+
module: outlineShaderModule,
|
|
529
|
+
buffers: [
|
|
530
|
+
{
|
|
531
|
+
arrayStride: 8 * 4,
|
|
532
|
+
attributes: [
|
|
533
|
+
{
|
|
534
|
+
shaderLocation: 0,
|
|
535
|
+
offset: 0,
|
|
536
|
+
format: "float32x3",
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
shaderLocation: 1,
|
|
540
|
+
offset: 3 * 4,
|
|
541
|
+
format: "float32x3",
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
shaderLocation: 2,
|
|
545
|
+
offset: 6 * 4,
|
|
546
|
+
format: "float32x2",
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
arrayStride: 4 * 2,
|
|
552
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
arrayStride: 4,
|
|
556
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
fragment: {
|
|
561
|
+
module: outlineShaderModule,
|
|
562
|
+
targets: [
|
|
563
|
+
{
|
|
564
|
+
format: this.presentationFormat,
|
|
565
|
+
blend: {
|
|
566
|
+
color: {
|
|
567
|
+
srcFactor: "src-alpha",
|
|
568
|
+
dstFactor: "one-minus-src-alpha",
|
|
569
|
+
operation: "add",
|
|
570
|
+
},
|
|
571
|
+
alpha: {
|
|
572
|
+
srcFactor: "one",
|
|
573
|
+
dstFactor: "one-minus-src-alpha",
|
|
574
|
+
operation: "add",
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
},
|
|
580
|
+
primitive: {
|
|
581
|
+
cullMode: "back",
|
|
582
|
+
},
|
|
583
|
+
depthStencil: {
|
|
584
|
+
format: "depth24plus-stencil8",
|
|
585
|
+
depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
|
|
586
|
+
depthCompare: "less-equal", // Only draw where hair depth exists
|
|
587
|
+
stencilFront: {
|
|
588
|
+
compare: "not-equal", // Only render where stencil != 1 (not over eyes)
|
|
589
|
+
failOp: "keep",
|
|
590
|
+
depthFailOp: "keep",
|
|
591
|
+
passOp: "keep",
|
|
592
|
+
},
|
|
593
|
+
stencilBack: {
|
|
594
|
+
compare: "not-equal",
|
|
595
|
+
failOp: "keep",
|
|
596
|
+
depthFailOp: "keep",
|
|
597
|
+
passOp: "keep",
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
multisample: {
|
|
601
|
+
count: this.sampleCount,
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
// Hair outline pipeline for over eyes: draws where stencil == 1, but only where hair depth exists
|
|
605
|
+
// Uses depth compare "equal" with a small bias to only appear where hair geometry exists
|
|
606
|
+
this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
|
|
607
|
+
label: "hair outline over eyes pipeline",
|
|
608
|
+
layout: outlinePipelineLayout,
|
|
609
|
+
vertex: {
|
|
610
|
+
module: outlineShaderModule,
|
|
611
|
+
buffers: [
|
|
612
|
+
{
|
|
613
|
+
arrayStride: 8 * 4,
|
|
614
|
+
attributes: [
|
|
615
|
+
{
|
|
616
|
+
shaderLocation: 0,
|
|
617
|
+
offset: 0,
|
|
618
|
+
format: "float32x3",
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
shaderLocation: 1,
|
|
622
|
+
offset: 3 * 4,
|
|
623
|
+
format: "float32x3",
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
shaderLocation: 2,
|
|
627
|
+
offset: 6 * 4,
|
|
628
|
+
format: "float32x2",
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
arrayStride: 4 * 2,
|
|
634
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
arrayStride: 4,
|
|
638
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
fragment: {
|
|
643
|
+
module: outlineShaderModule,
|
|
644
|
+
targets: [
|
|
645
|
+
{
|
|
646
|
+
format: this.presentationFormat,
|
|
647
|
+
blend: {
|
|
648
|
+
color: {
|
|
649
|
+
srcFactor: "src-alpha",
|
|
650
|
+
dstFactor: "one-minus-src-alpha",
|
|
651
|
+
operation: "add",
|
|
652
|
+
},
|
|
653
|
+
alpha: {
|
|
654
|
+
srcFactor: "one",
|
|
655
|
+
dstFactor: "one-minus-src-alpha",
|
|
656
|
+
operation: "add",
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
],
|
|
661
|
+
},
|
|
662
|
+
primitive: {
|
|
663
|
+
cullMode: "back",
|
|
664
|
+
},
|
|
665
|
+
depthStencil: {
|
|
666
|
+
format: "depth24plus-stencil8",
|
|
667
|
+
depthWriteEnabled: false, // Don't write depth
|
|
668
|
+
depthCompare: "less-equal", // Draw where outline depth <= existing depth (hair depth)
|
|
669
|
+
depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
|
|
670
|
+
depthBiasSlopeScale: 0.0,
|
|
671
|
+
depthBiasClamp: 0.0,
|
|
672
|
+
stencilFront: {
|
|
673
|
+
compare: "equal", // Only render where stencil == 1 (over eyes)
|
|
674
|
+
failOp: "keep",
|
|
675
|
+
depthFailOp: "keep",
|
|
676
|
+
passOp: "keep",
|
|
677
|
+
},
|
|
678
|
+
stencilBack: {
|
|
679
|
+
compare: "equal",
|
|
680
|
+
failOp: "keep",
|
|
681
|
+
depthFailOp: "keep",
|
|
682
|
+
passOp: "keep",
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
multisample: {
|
|
686
|
+
count: this.sampleCount,
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
// Hair pipeline with multiplicative blending (for hair over eyes)
|
|
690
|
+
this.hairMultiplyPipeline = this.device.createRenderPipeline({
|
|
691
|
+
label: "hair multiply pipeline",
|
|
692
|
+
layout: sharedPipelineLayout,
|
|
693
|
+
vertex: {
|
|
694
|
+
module: hairMultiplyShaderModule,
|
|
695
|
+
buffers: [
|
|
696
|
+
{
|
|
697
|
+
arrayStride: 8 * 4,
|
|
698
|
+
attributes: [
|
|
699
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" },
|
|
700
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
|
|
701
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
|
|
702
|
+
],
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
arrayStride: 4 * 2,
|
|
706
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
arrayStride: 4,
|
|
710
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
|
|
711
|
+
},
|
|
712
|
+
],
|
|
713
|
+
},
|
|
714
|
+
fragment: {
|
|
715
|
+
module: hairMultiplyShaderModule,
|
|
716
|
+
targets: [
|
|
717
|
+
{
|
|
718
|
+
format: this.presentationFormat,
|
|
719
|
+
blend: {
|
|
720
|
+
color: {
|
|
721
|
+
// Simple half-transparent overlay effect
|
|
722
|
+
// Blend: hairColor * overlayAlpha + eyeColor * (1 - overlayAlpha)
|
|
723
|
+
srcFactor: "src-alpha",
|
|
724
|
+
dstFactor: "one-minus-src-alpha",
|
|
725
|
+
operation: "add",
|
|
726
|
+
},
|
|
727
|
+
alpha: {
|
|
728
|
+
srcFactor: "one",
|
|
729
|
+
dstFactor: "one-minus-src-alpha",
|
|
730
|
+
operation: "add",
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
},
|
|
736
|
+
primitive: { cullMode: "none" },
|
|
737
|
+
depthStencil: {
|
|
738
|
+
format: "depth24plus-stencil8",
|
|
739
|
+
depthWriteEnabled: true, // Write depth so outlines can test against it
|
|
740
|
+
depthCompare: "less",
|
|
741
|
+
stencilFront: {
|
|
742
|
+
compare: "equal", // Only render where stencil == 1
|
|
743
|
+
failOp: "keep",
|
|
744
|
+
depthFailOp: "keep",
|
|
745
|
+
passOp: "keep",
|
|
746
|
+
},
|
|
747
|
+
stencilBack: {
|
|
748
|
+
compare: "equal",
|
|
749
|
+
failOp: "keep",
|
|
750
|
+
depthFailOp: "keep",
|
|
751
|
+
passOp: "keep",
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
multisample: { count: this.sampleCount },
|
|
755
|
+
});
|
|
756
|
+
// Hair pipeline for opaque rendering (hair over non-eyes)
|
|
757
|
+
this.hairOpaquePipeline = this.device.createRenderPipeline({
|
|
758
|
+
label: "hair opaque pipeline",
|
|
759
|
+
layout: sharedPipelineLayout,
|
|
760
|
+
vertex: {
|
|
761
|
+
module: shaderModule,
|
|
762
|
+
buffers: [
|
|
763
|
+
{
|
|
764
|
+
arrayStride: 8 * 4,
|
|
765
|
+
attributes: [
|
|
766
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" },
|
|
767
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
|
|
768
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
|
|
769
|
+
],
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
arrayStride: 4 * 2,
|
|
773
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
arrayStride: 4,
|
|
777
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
|
|
778
|
+
},
|
|
779
|
+
],
|
|
780
|
+
},
|
|
781
|
+
fragment: {
|
|
782
|
+
module: shaderModule,
|
|
783
|
+
targets: [
|
|
784
|
+
{
|
|
785
|
+
format: this.presentationFormat,
|
|
786
|
+
blend: {
|
|
787
|
+
color: {
|
|
788
|
+
srcFactor: "src-alpha",
|
|
789
|
+
dstFactor: "one-minus-src-alpha",
|
|
790
|
+
operation: "add",
|
|
791
|
+
},
|
|
792
|
+
alpha: {
|
|
793
|
+
srcFactor: "one",
|
|
794
|
+
dstFactor: "one-minus-src-alpha",
|
|
795
|
+
operation: "add",
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
],
|
|
800
|
+
},
|
|
801
|
+
primitive: { cullMode: "none" },
|
|
802
|
+
depthStencil: {
|
|
803
|
+
format: "depth24plus-stencil8",
|
|
804
|
+
depthWriteEnabled: true,
|
|
805
|
+
depthCompare: "less",
|
|
806
|
+
stencilFront: {
|
|
807
|
+
compare: "not-equal", // Only render where stencil != 1
|
|
808
|
+
failOp: "keep",
|
|
809
|
+
depthFailOp: "keep",
|
|
810
|
+
passOp: "keep",
|
|
811
|
+
},
|
|
812
|
+
stencilBack: {
|
|
813
|
+
compare: "not-equal",
|
|
814
|
+
failOp: "keep",
|
|
815
|
+
depthFailOp: "keep",
|
|
816
|
+
passOp: "keep",
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
multisample: { count: this.sampleCount },
|
|
820
|
+
});
|
|
821
|
+
// Eye overlay pipeline (renders after opaque, writes stencil)
|
|
822
|
+
this.eyePipeline = this.device.createRenderPipeline({
|
|
823
|
+
label: "eye overlay pipeline",
|
|
824
|
+
layout: sharedPipelineLayout,
|
|
825
|
+
vertex: {
|
|
826
|
+
module: shaderModule,
|
|
827
|
+
buffers: [
|
|
828
|
+
{
|
|
829
|
+
arrayStride: 8 * 4,
|
|
830
|
+
attributes: [
|
|
831
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" },
|
|
832
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
|
|
833
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
|
|
834
|
+
],
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
arrayStride: 4 * 2,
|
|
838
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
arrayStride: 4,
|
|
842
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
},
|
|
846
|
+
fragment: {
|
|
847
|
+
module: shaderModule,
|
|
848
|
+
targets: [
|
|
849
|
+
{
|
|
850
|
+
format: this.presentationFormat,
|
|
851
|
+
blend: {
|
|
852
|
+
color: {
|
|
853
|
+
srcFactor: "src-alpha",
|
|
854
|
+
dstFactor: "one-minus-src-alpha",
|
|
855
|
+
operation: "add",
|
|
856
|
+
},
|
|
857
|
+
alpha: {
|
|
858
|
+
srcFactor: "one",
|
|
859
|
+
dstFactor: "one-minus-src-alpha",
|
|
860
|
+
operation: "add",
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
],
|
|
865
|
+
},
|
|
866
|
+
primitive: { cullMode: "none" },
|
|
867
|
+
depthStencil: {
|
|
868
|
+
format: "depth24plus-stencil8",
|
|
869
|
+
depthWriteEnabled: false, // Don't write depth
|
|
870
|
+
depthCompare: "less", // Respect existing depth
|
|
871
|
+
stencilFront: {
|
|
872
|
+
compare: "always",
|
|
873
|
+
failOp: "keep",
|
|
874
|
+
depthFailOp: "keep",
|
|
875
|
+
passOp: "replace", // Write stencil value 1
|
|
876
|
+
},
|
|
877
|
+
stencilBack: {
|
|
878
|
+
compare: "always",
|
|
879
|
+
failOp: "keep",
|
|
880
|
+
depthFailOp: "keep",
|
|
881
|
+
passOp: "replace",
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
multisample: { count: this.sampleCount },
|
|
885
|
+
});
|
|
378
886
|
}
|
|
379
887
|
// Create compute shader for skin matrix computation
|
|
380
888
|
createSkinMatrixComputePipeline() {
|
|
381
889
|
const computeShader = this.device.createShaderModule({
|
|
382
890
|
label: "skin matrix compute",
|
|
383
|
-
code: /* wgsl */ `
|
|
384
|
-
struct BoneCountUniform {
|
|
385
|
-
count: u32,
|
|
386
|
-
_padding1: u32,
|
|
387
|
-
_padding2: u32,
|
|
388
|
-
_padding3: u32,
|
|
389
|
-
_padding4: vec4<u32>,
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
@group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
|
|
393
|
-
@group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
|
|
394
|
-
@group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
|
|
395
|
-
@group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
|
|
396
|
-
|
|
397
|
-
@compute @workgroup_size(64)
|
|
398
|
-
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
|
399
|
-
let boneIndex = globalId.x;
|
|
400
|
-
// Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
|
|
401
|
-
if (boneIndex >= boneCount.count) {
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
let worldMat = worldMatrices[boneIndex];
|
|
405
|
-
let invBindMat = inverseBindMatrices[boneIndex];
|
|
406
|
-
skinMatrices[boneIndex] = worldMat * invBindMat;
|
|
407
|
-
}
|
|
891
|
+
code: /* wgsl */ `
|
|
892
|
+
struct BoneCountUniform {
|
|
893
|
+
count: u32,
|
|
894
|
+
_padding1: u32,
|
|
895
|
+
_padding2: u32,
|
|
896
|
+
_padding3: u32,
|
|
897
|
+
_padding4: vec4<u32>,
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
@group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
|
|
901
|
+
@group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
|
|
902
|
+
@group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
|
|
903
|
+
@group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
|
|
904
|
+
|
|
905
|
+
@compute @workgroup_size(64)
|
|
906
|
+
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
|
907
|
+
let boneIndex = globalId.x;
|
|
908
|
+
// Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
|
|
909
|
+
if (boneIndex >= boneCount.count) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
let worldMat = worldMatrices[boneIndex];
|
|
913
|
+
let invBindMat = inverseBindMatrices[boneIndex];
|
|
914
|
+
skinMatrices[boneIndex] = worldMat * invBindMat;
|
|
915
|
+
}
|
|
408
916
|
`,
|
|
409
917
|
});
|
|
410
918
|
this.skinMatrixComputePipeline = this.device.createComputePipeline({
|
|
@@ -441,7 +949,7 @@ export class Engine {
|
|
|
441
949
|
label: "depth texture",
|
|
442
950
|
size: [width, height],
|
|
443
951
|
sampleCount: this.sampleCount,
|
|
444
|
-
format: "depth24plus",
|
|
952
|
+
format: "depth24plus-stencil8",
|
|
445
953
|
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
446
954
|
});
|
|
447
955
|
const depthTextureView = this.depthTexture.createView();
|
|
@@ -467,6 +975,9 @@ export class Engine {
|
|
|
467
975
|
depthClearValue: 1.0,
|
|
468
976
|
depthLoadOp: "clear",
|
|
469
977
|
depthStoreOp: "store",
|
|
978
|
+
stencilClearValue: 0, // New: clear stencil to 0
|
|
979
|
+
stencilLoadOp: "clear", // New: clear stencil each frame
|
|
980
|
+
stencilStoreOp: "store", // New: store stencil
|
|
470
981
|
},
|
|
471
982
|
};
|
|
472
983
|
this.camera.aspect = width / height;
|
|
@@ -646,7 +1157,11 @@ export class Engine {
|
|
|
646
1157
|
const texture = await loadTextureByIndex(toonTextureIndex);
|
|
647
1158
|
if (texture)
|
|
648
1159
|
return texture;
|
|
649
|
-
// Default toon texture fallback
|
|
1160
|
+
// Default toon texture fallback - cache it
|
|
1161
|
+
const defaultToonPath = "__default_toon__";
|
|
1162
|
+
const cached = this.textureCache.get(defaultToonPath);
|
|
1163
|
+
if (cached)
|
|
1164
|
+
return cached;
|
|
650
1165
|
const defaultToonData = new Uint8Array(256 * 2 * 4);
|
|
651
1166
|
for (let i = 0; i < 256; i++) {
|
|
652
1167
|
const factor = i / 255.0;
|
|
@@ -667,12 +1182,18 @@ export class Engine {
|
|
|
667
1182
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
668
1183
|
});
|
|
669
1184
|
this.device.queue.writeTexture({ texture: defaultToonTexture }, defaultToonData, { bytesPerRow: 256 * 4 }, [256, 2]);
|
|
670
|
-
this.
|
|
1185
|
+
this.textureCache.set(defaultToonPath, defaultToonTexture);
|
|
1186
|
+
this.textureSizes.set(defaultToonPath, { width: 256, height: 2 });
|
|
671
1187
|
return defaultToonTexture;
|
|
672
1188
|
};
|
|
673
|
-
this.
|
|
674
|
-
this.
|
|
675
|
-
|
|
1189
|
+
this.opaqueNonEyeNonHairDraws = [];
|
|
1190
|
+
this.eyeDraws = [];
|
|
1191
|
+
this.hairDraws = [];
|
|
1192
|
+
this.transparentNonEyeNonHairDraws = [];
|
|
1193
|
+
this.opaqueNonEyeNonHairOutlineDraws = [];
|
|
1194
|
+
this.eyeOutlineDraws = [];
|
|
1195
|
+
this.hairOutlineDraws = [];
|
|
1196
|
+
this.transparentNonEyeNonHairOutlineDraws = [];
|
|
676
1197
|
let runningFirstIndex = 0;
|
|
677
1198
|
for (const mat of materials) {
|
|
678
1199
|
const matCount = mat.vertexCount | 0;
|
|
@@ -696,9 +1217,11 @@ export class Engine {
|
|
|
696
1217
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
697
1218
|
});
|
|
698
1219
|
this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData);
|
|
1220
|
+
// Create bind groups using the shared bind group layout
|
|
1221
|
+
// All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
|
|
699
1222
|
const bindGroup = this.device.createBindGroup({
|
|
700
1223
|
label: `material bind group: ${mat.name}`,
|
|
701
|
-
layout: this.
|
|
1224
|
+
layout: this.hairBindGroupLayout,
|
|
702
1225
|
entries: [
|
|
703
1226
|
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
704
1227
|
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
@@ -710,13 +1233,39 @@ export class Engine {
|
|
|
710
1233
|
{ binding: 7, resource: { buffer: materialUniformBuffer } },
|
|
711
1234
|
],
|
|
712
1235
|
});
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1236
|
+
// Classify materials into appropriate draw lists
|
|
1237
|
+
if (mat.isEye) {
|
|
1238
|
+
this.eyeDraws.push({
|
|
1239
|
+
count: matCount,
|
|
1240
|
+
firstIndex: runningFirstIndex,
|
|
1241
|
+
bindGroup,
|
|
1242
|
+
isTransparent,
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
else if (mat.isHair) {
|
|
1246
|
+
this.hairDraws.push({
|
|
1247
|
+
count: matCount,
|
|
1248
|
+
firstIndex: runningFirstIndex,
|
|
1249
|
+
bindGroup,
|
|
1250
|
+
isTransparent,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
else if (isTransparent) {
|
|
1254
|
+
this.transparentNonEyeNonHairDraws.push({
|
|
1255
|
+
count: matCount,
|
|
1256
|
+
firstIndex: runningFirstIndex,
|
|
1257
|
+
bindGroup,
|
|
1258
|
+
isTransparent,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
this.opaqueNonEyeNonHairDraws.push({
|
|
1263
|
+
count: matCount,
|
|
1264
|
+
firstIndex: runningFirstIndex,
|
|
1265
|
+
bindGroup,
|
|
1266
|
+
isTransparent,
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
720
1269
|
// Outline for all materials (including transparent)
|
|
721
1270
|
// Edge flag is at bit 4 (0x10) in PMX format, not bit 0 (0x01)
|
|
722
1271
|
if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
|
|
@@ -734,26 +1283,52 @@ export class Engine {
|
|
|
734
1283
|
this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData);
|
|
735
1284
|
const outlineBindGroup = this.device.createBindGroup({
|
|
736
1285
|
label: `outline bind group: ${mat.name}`,
|
|
737
|
-
layout: outlineBindGroupLayout,
|
|
1286
|
+
layout: this.outlineBindGroupLayout,
|
|
738
1287
|
entries: [
|
|
739
1288
|
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
740
1289
|
{ binding: 1, resource: { buffer: materialUniformBuffer } },
|
|
741
1290
|
{ binding: 2, resource: { buffer: this.skinMatrixBuffer } },
|
|
742
1291
|
],
|
|
743
1292
|
});
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1293
|
+
// Classify outlines into appropriate draw lists
|
|
1294
|
+
if (mat.isEye) {
|
|
1295
|
+
this.eyeOutlineDraws.push({
|
|
1296
|
+
count: matCount,
|
|
1297
|
+
firstIndex: runningFirstIndex,
|
|
1298
|
+
bindGroup: outlineBindGroup,
|
|
1299
|
+
isTransparent,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
else if (mat.isHair) {
|
|
1303
|
+
this.hairOutlineDraws.push({
|
|
1304
|
+
count: matCount,
|
|
1305
|
+
firstIndex: runningFirstIndex,
|
|
1306
|
+
bindGroup: outlineBindGroup,
|
|
1307
|
+
isTransparent,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
else if (isTransparent) {
|
|
1311
|
+
this.transparentNonEyeNonHairOutlineDraws.push({
|
|
1312
|
+
count: matCount,
|
|
1313
|
+
firstIndex: runningFirstIndex,
|
|
1314
|
+
bindGroup: outlineBindGroup,
|
|
1315
|
+
isTransparent,
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
this.opaqueNonEyeNonHairOutlineDraws.push({
|
|
1320
|
+
count: matCount,
|
|
1321
|
+
firstIndex: runningFirstIndex,
|
|
1322
|
+
bindGroup: outlineBindGroup,
|
|
1323
|
+
isTransparent,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
751
1326
|
}
|
|
752
1327
|
runningFirstIndex += matCount;
|
|
753
1328
|
}
|
|
754
1329
|
}
|
|
755
|
-
// Helper: Load texture from file path
|
|
756
|
-
async createTextureFromPath(path) {
|
|
1330
|
+
// Helper: Load texture from file path with optional max size limit
|
|
1331
|
+
async createTextureFromPath(path, maxSize = 2048) {
|
|
757
1332
|
const cached = this.textureCache.get(path);
|
|
758
1333
|
if (cached) {
|
|
759
1334
|
return cached;
|
|
@@ -763,22 +1338,34 @@ export class Engine {
|
|
|
763
1338
|
if (!response.ok) {
|
|
764
1339
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
765
1340
|
}
|
|
766
|
-
|
|
1341
|
+
let imageBitmap = await createImageBitmap(await response.blob(), {
|
|
767
1342
|
premultiplyAlpha: "none",
|
|
768
1343
|
colorSpaceConversion: "none",
|
|
769
1344
|
});
|
|
1345
|
+
// Downscale if texture is too large
|
|
1346
|
+
let finalWidth = imageBitmap.width;
|
|
1347
|
+
let finalHeight = imageBitmap.height;
|
|
1348
|
+
if (finalWidth > maxSize || finalHeight > maxSize) {
|
|
1349
|
+
const scale = Math.min(maxSize / finalWidth, maxSize / finalHeight);
|
|
1350
|
+
finalWidth = Math.floor(finalWidth * scale);
|
|
1351
|
+
finalHeight = Math.floor(finalHeight * scale);
|
|
1352
|
+
// Create canvas to downscale
|
|
1353
|
+
const canvas = new OffscreenCanvas(finalWidth, finalHeight);
|
|
1354
|
+
const ctx = canvas.getContext("2d");
|
|
1355
|
+
if (ctx) {
|
|
1356
|
+
ctx.drawImage(imageBitmap, 0, 0, finalWidth, finalHeight);
|
|
1357
|
+
imageBitmap = await createImageBitmap(canvas);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
770
1360
|
const texture = this.device.createTexture({
|
|
771
1361
|
label: `texture: ${path}`,
|
|
772
|
-
size: [
|
|
1362
|
+
size: [finalWidth, finalHeight],
|
|
773
1363
|
format: "rgba8unorm",
|
|
774
1364
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
775
1365
|
});
|
|
776
|
-
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
|
|
777
|
-
imageBitmap.width,
|
|
778
|
-
imageBitmap.height,
|
|
779
|
-
]);
|
|
1366
|
+
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [finalWidth, finalHeight]);
|
|
780
1367
|
this.textureCache.set(path, texture);
|
|
781
|
-
this.textureSizes.set(path, { width:
|
|
1368
|
+
this.textureSizes.set(path, { width: finalWidth, height: finalHeight });
|
|
782
1369
|
return texture;
|
|
783
1370
|
}
|
|
784
1371
|
catch {
|
|
@@ -801,10 +1388,78 @@ export class Engine {
|
|
|
801
1388
|
pass.setVertexBuffer(2, this.weightsBuffer);
|
|
802
1389
|
pass.setIndexBuffer(this.indexBuffer, "uint32");
|
|
803
1390
|
this.drawCallCount = 0;
|
|
804
|
-
|
|
805
|
-
this.
|
|
806
|
-
this.
|
|
807
|
-
this.
|
|
1391
|
+
// === PASS 1: Opaque non-eye, non-hair (face, body, etc) ===
|
|
1392
|
+
this.drawOutlines(pass, false); // Opaque outlines
|
|
1393
|
+
pass.setPipeline(this.pipeline);
|
|
1394
|
+
for (const draw of this.opaqueNonEyeNonHairDraws) {
|
|
1395
|
+
if (draw.count > 0) {
|
|
1396
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1397
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1398
|
+
this.drawCallCount++;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// === PASS 2: Eyes (writes stencil = 1) ===
|
|
1402
|
+
pass.setPipeline(this.eyePipeline);
|
|
1403
|
+
pass.setStencilReference(1); // Set stencil reference value to 1
|
|
1404
|
+
for (const draw of this.eyeDraws) {
|
|
1405
|
+
if (draw.count > 0) {
|
|
1406
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1407
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1408
|
+
this.drawCallCount++;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
// === PASS 3a: Hair over eyes (stencil == 1, multiply blend) ===
|
|
1412
|
+
// Draw hair geometry first to establish depth
|
|
1413
|
+
pass.setPipeline(this.hairMultiplyPipeline);
|
|
1414
|
+
pass.setStencilReference(1); // Check against stencil value 1
|
|
1415
|
+
for (const draw of this.hairDraws) {
|
|
1416
|
+
if (draw.count > 0) {
|
|
1417
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1418
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1419
|
+
this.drawCallCount++;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
// === PASS 3a.5: Hair outlines over eyes (stencil == 1, depth test to only draw near hair) ===
|
|
1423
|
+
// Use depth compare "less-equal" with the hair depth to only draw outline where hair exists
|
|
1424
|
+
// The outline is expanded outward, so we need to ensure it only appears near the hair edge
|
|
1425
|
+
pass.setPipeline(this.hairOutlineOverEyesPipeline);
|
|
1426
|
+
pass.setStencilReference(1); // Check against stencil value 1 (with equal test)
|
|
1427
|
+
for (const draw of this.hairOutlineDraws) {
|
|
1428
|
+
if (draw.count > 0) {
|
|
1429
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1430
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
// === PASS 3b: Hair over non-eyes (stencil != 1, opaque) ===
|
|
1434
|
+
pass.setPipeline(this.hairOpaquePipeline);
|
|
1435
|
+
pass.setStencilReference(1); // Check against stencil value 1 (with not-equal test)
|
|
1436
|
+
for (const draw of this.hairDraws) {
|
|
1437
|
+
if (draw.count > 0) {
|
|
1438
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1439
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1440
|
+
this.drawCallCount++;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
// === PASS 3b.5: Hair outlines over non-eyes (stencil != 1) ===
|
|
1444
|
+
// Draw hair outlines after hair geometry, so they only appear where hair exists
|
|
1445
|
+
pass.setPipeline(this.hairOutlinePipeline);
|
|
1446
|
+
pass.setStencilReference(1); // Check against stencil value 1 (with not-equal test)
|
|
1447
|
+
for (const draw of this.hairOutlineDraws) {
|
|
1448
|
+
if (draw.count > 0) {
|
|
1449
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1450
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
// === PASS 4: Transparent non-eye, non-hair ===
|
|
1454
|
+
pass.setPipeline(this.pipeline);
|
|
1455
|
+
for (const draw of this.transparentNonEyeNonHairDraws) {
|
|
1456
|
+
if (draw.count > 0) {
|
|
1457
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1458
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1459
|
+
this.drawCallCount++;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
this.drawOutlines(pass, true); // Transparent outlines
|
|
808
1463
|
pass.end();
|
|
809
1464
|
this.device.queue.submit([encoder.finish()]);
|
|
810
1465
|
this.updateStats(performance.now() - currentTime);
|
|
@@ -876,24 +1531,23 @@ export class Engine {
|
|
|
876
1531
|
}
|
|
877
1532
|
// Draw outlines (opaque or transparent)
|
|
878
1533
|
drawOutlines(pass, transparent) {
|
|
879
|
-
if (this.outlineDraws.length === 0)
|
|
880
|
-
return;
|
|
881
1534
|
pass.setPipeline(this.outlinePipeline);
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1535
|
+
if (transparent) {
|
|
1536
|
+
// Draw transparent outlines (if any)
|
|
1537
|
+
for (const draw of this.transparentNonEyeNonHairOutlineDraws) {
|
|
1538
|
+
if (draw.count > 0) {
|
|
1539
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1540
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1541
|
+
}
|
|
886
1542
|
}
|
|
887
1543
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
896
|
-
this.drawCallCount++;
|
|
1544
|
+
else {
|
|
1545
|
+
// Draw opaque outlines before main geometry
|
|
1546
|
+
for (const draw of this.opaqueNonEyeNonHairOutlineDraws) {
|
|
1547
|
+
if (draw.count > 0) {
|
|
1548
|
+
pass.setBindGroup(0, draw.bindGroup);
|
|
1549
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1550
|
+
}
|
|
897
1551
|
}
|
|
898
1552
|
}
|
|
899
1553
|
}
|
|
@@ -914,41 +1568,14 @@ export class Engine {
|
|
|
914
1568
|
this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000);
|
|
915
1569
|
this.framesSinceLastUpdate = 0;
|
|
916
1570
|
this.lastFpsUpdate = now;
|
|
917
|
-
const perf = performance;
|
|
918
|
-
if (perf.memory) {
|
|
919
|
-
this.stats.memoryUsed = Math.round(perf.memory.usedJSHeapSize / 1024 / 1024);
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
this.stats.vertices = this.vertexCount;
|
|
923
|
-
this.stats.drawCalls = this.drawCallCount;
|
|
924
|
-
// Calculate triangles from index buffer
|
|
925
|
-
if (this.indexBuffer) {
|
|
926
|
-
const indexCount = this.currentModel?.getIndices()?.length || 0;
|
|
927
|
-
this.stats.triangles = Math.floor(indexCount / 3);
|
|
928
|
-
}
|
|
929
|
-
else {
|
|
930
|
-
this.stats.triangles = Math.floor(this.vertexCount / 3);
|
|
931
1571
|
}
|
|
932
|
-
//
|
|
933
|
-
this.stats.materials = this.materialDraws.length;
|
|
934
|
-
// Texture stats
|
|
935
|
-
this.stats.textures = this.textureCache.size;
|
|
1572
|
+
// Calculate GPU memory: textures + buffers + render targets
|
|
936
1573
|
let textureMemoryBytes = 0;
|
|
937
1574
|
for (const [path, size] of this.textureSizes.entries()) {
|
|
938
1575
|
if (this.textureCache.has(path)) {
|
|
939
|
-
// RGBA8 = 4 bytes per pixel
|
|
940
|
-
textureMemoryBytes += size.width * size.height * 4;
|
|
1576
|
+
textureMemoryBytes += size.width * size.height * 4; // RGBA8 = 4 bytes per pixel
|
|
941
1577
|
}
|
|
942
1578
|
}
|
|
943
|
-
// Add render target textures (multisample + depth)
|
|
944
|
-
if (this.multisampleTexture) {
|
|
945
|
-
const width = this.canvas.width;
|
|
946
|
-
const height = this.canvas.height;
|
|
947
|
-
textureMemoryBytes += width * height * 4 * this.sampleCount; // multisample color
|
|
948
|
-
textureMemoryBytes += width * height * 4; // depth (depth24plus = 4 bytes)
|
|
949
|
-
}
|
|
950
|
-
this.stats.textureMemory = Math.round((textureMemoryBytes / 1024 / 1024) * 100) / 100;
|
|
951
|
-
// Buffer memory estimate
|
|
952
1579
|
let bufferMemoryBytes = 0;
|
|
953
1580
|
if (this.vertexBuffer) {
|
|
954
1581
|
const vertices = this.currentModel?.getVertices();
|
|
@@ -977,10 +1604,19 @@ export class Engine {
|
|
|
977
1604
|
}
|
|
978
1605
|
bufferMemoryBytes += 40 * 4; // cameraUniformBuffer
|
|
979
1606
|
bufferMemoryBytes += 64 * 4; // lightUniformBuffer
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1607
|
+
const totalMaterialDraws = this.opaqueNonEyeNonHairDraws.length +
|
|
1608
|
+
this.eyeDraws.length +
|
|
1609
|
+
this.hairDraws.length +
|
|
1610
|
+
this.transparentNonEyeNonHairDraws.length;
|
|
1611
|
+
bufferMemoryBytes += totalMaterialDraws * 4; // Material uniform buffers
|
|
1612
|
+
let renderTargetMemoryBytes = 0;
|
|
1613
|
+
if (this.multisampleTexture) {
|
|
1614
|
+
const width = this.canvas.width;
|
|
1615
|
+
const height = this.canvas.height;
|
|
1616
|
+
renderTargetMemoryBytes += width * height * 4 * this.sampleCount; // multisample color
|
|
1617
|
+
renderTargetMemoryBytes += width * height * 4; // depth
|
|
1618
|
+
}
|
|
1619
|
+
const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes;
|
|
1620
|
+
this.stats.gpuMemory = Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100;
|
|
985
1621
|
}
|
|
986
1622
|
}
|