reze-engine 0.2.11 → 0.2.13
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.map +1 -1
- package/dist/engine.js +436 -434
- package/dist/pool.d.ts +38 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/pool.js +422 -0
- package/package.json +1 -1
- package/src/camera.ts +358 -358
- package/src/engine.ts +2404 -2402
- 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/src/engine.ts
CHANGED
|
@@ -1,2402 +1,2404 @@
|
|
|
1
|
-
import { Camera } from "./camera"
|
|
2
|
-
import { Quat, Vec3 } from "./math"
|
|
3
|
-
import { Model } from "./model"
|
|
4
|
-
import { PmxLoader } from "./pmx-loader"
|
|
5
|
-
import { Physics } from "./physics"
|
|
6
|
-
import { VMDKeyFrame, VMDLoader } from "./vmd-loader"
|
|
7
|
-
|
|
8
|
-
export type EngineOptions = {
|
|
9
|
-
ambient?: number
|
|
10
|
-
bloomIntensity?: number
|
|
11
|
-
rimLightIntensity?: number
|
|
12
|
-
cameraDistance?: number
|
|
13
|
-
cameraTarget?: Vec3
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface EngineStats {
|
|
17
|
-
fps: number
|
|
18
|
-
frameTime: number // ms
|
|
19
|
-
gpuMemory: number // MB (estimated total GPU memory)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface DrawCall {
|
|
23
|
-
count: number
|
|
24
|
-
firstIndex: number
|
|
25
|
-
bindGroup: GPUBindGroup
|
|
26
|
-
isTransparent: boolean
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
type BoneKeyFrame = {
|
|
30
|
-
boneName: string
|
|
31
|
-
time: number
|
|
32
|
-
rotation: Quat
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export class Engine {
|
|
36
|
-
private canvas: HTMLCanvasElement
|
|
37
|
-
private device!: GPUDevice
|
|
38
|
-
private context!: GPUCanvasContext
|
|
39
|
-
private presentationFormat!: GPUTextureFormat
|
|
40
|
-
private camera!: Camera
|
|
41
|
-
private cameraUniformBuffer!: GPUBuffer
|
|
42
|
-
private cameraMatrixData = new Float32Array(36)
|
|
43
|
-
private cameraDistance: number = 26.6
|
|
44
|
-
private cameraTarget: Vec3 = new Vec3(0, 12.5, 0)
|
|
45
|
-
private lightUniformBuffer!: GPUBuffer
|
|
46
|
-
private lightData = new Float32Array(64)
|
|
47
|
-
private lightCount = 0
|
|
48
|
-
private vertexBuffer!: GPUBuffer
|
|
49
|
-
private indexBuffer?: GPUBuffer
|
|
50
|
-
private resizeObserver: ResizeObserver | null = null
|
|
51
|
-
private depthTexture!: GPUTexture
|
|
52
|
-
// Material rendering pipelines
|
|
53
|
-
private modelPipeline!: GPURenderPipeline
|
|
54
|
-
private eyePipeline!: GPURenderPipeline
|
|
55
|
-
private hairPipelineOverEyes!: GPURenderPipeline
|
|
56
|
-
private hairPipelineOverNonEyes!: GPURenderPipeline
|
|
57
|
-
private hairDepthPipeline!: GPURenderPipeline
|
|
58
|
-
// Outline pipelines
|
|
59
|
-
private outlinePipeline!: GPURenderPipeline
|
|
60
|
-
private hairOutlinePipeline!: GPURenderPipeline
|
|
61
|
-
private mainBindGroupLayout!: GPUBindGroupLayout
|
|
62
|
-
private outlineBindGroupLayout!: GPUBindGroupLayout
|
|
63
|
-
private jointsBuffer!: GPUBuffer
|
|
64
|
-
private weightsBuffer!: GPUBuffer
|
|
65
|
-
private skinMatrixBuffer?: GPUBuffer
|
|
66
|
-
private worldMatrixBuffer?: GPUBuffer
|
|
67
|
-
private inverseBindMatrixBuffer?: GPUBuffer
|
|
68
|
-
private skinMatrixComputePipeline?: GPUComputePipeline
|
|
69
|
-
private skinMatrixComputeBindGroup?: GPUBindGroup
|
|
70
|
-
private boneCountBuffer?: GPUBuffer
|
|
71
|
-
private multisampleTexture!: GPUTexture
|
|
72
|
-
private readonly sampleCount = 4
|
|
73
|
-
private renderPassDescriptor!: GPURenderPassDescriptor
|
|
74
|
-
// Constants
|
|
75
|
-
private readonly STENCIL_EYE_VALUE = 1
|
|
76
|
-
private readonly COMPUTE_WORKGROUP_SIZE = 64
|
|
77
|
-
private readonly BLOOM_DOWNSCALE_FACTOR = 2
|
|
78
|
-
// Ambient light settings
|
|
79
|
-
private ambient: number = 1.0
|
|
80
|
-
// Bloom post-processing textures
|
|
81
|
-
private sceneRenderTexture!: GPUTexture
|
|
82
|
-
private sceneRenderTextureView!: GPUTextureView
|
|
83
|
-
private bloomExtractTexture!: GPUTexture
|
|
84
|
-
private bloomBlurTexture1!: GPUTexture
|
|
85
|
-
private bloomBlurTexture2!: GPUTexture
|
|
86
|
-
// Post-processing pipelines
|
|
87
|
-
private bloomExtractPipeline!: GPURenderPipeline
|
|
88
|
-
private bloomBlurPipeline!: GPURenderPipeline
|
|
89
|
-
private bloomComposePipeline!: GPURenderPipeline
|
|
90
|
-
// Fullscreen quad for post-processing
|
|
91
|
-
private fullscreenQuadBuffer!: GPUBuffer
|
|
92
|
-
private blurDirectionBuffer!: GPUBuffer
|
|
93
|
-
private bloomIntensityBuffer!: GPUBuffer
|
|
94
|
-
private bloomThresholdBuffer!: GPUBuffer
|
|
95
|
-
private linearSampler!: GPUSampler
|
|
96
|
-
// Bloom bind groups (created once, reused every frame)
|
|
97
|
-
private bloomExtractBindGroup?: GPUBindGroup
|
|
98
|
-
private bloomBlurHBindGroup?: GPUBindGroup
|
|
99
|
-
private bloomBlurVBindGroup?: GPUBindGroup
|
|
100
|
-
private bloomComposeBindGroup?: GPUBindGroup
|
|
101
|
-
// Bloom settings
|
|
102
|
-
private bloomThreshold: number = 0.3
|
|
103
|
-
private bloomIntensity: number = 0.12
|
|
104
|
-
// Rim light settings
|
|
105
|
-
private rimLightIntensity: number = 0.45
|
|
106
|
-
|
|
107
|
-
private currentModel: Model | null = null
|
|
108
|
-
private modelDir: string = ""
|
|
109
|
-
private physics: Physics | null = null
|
|
110
|
-
private materialSampler!: GPUSampler
|
|
111
|
-
private textureCache = new Map<string, GPUTexture>()
|
|
112
|
-
// Draw lists
|
|
113
|
-
private opaqueDraws: DrawCall[] = []
|
|
114
|
-
private eyeDraws: DrawCall[] = []
|
|
115
|
-
private hairDrawsOverEyes: DrawCall[] = []
|
|
116
|
-
private hairDrawsOverNonEyes: DrawCall[] = []
|
|
117
|
-
private transparentDraws: DrawCall[] = []
|
|
118
|
-
private opaqueOutlineDraws: DrawCall[] = []
|
|
119
|
-
private eyeOutlineDraws: DrawCall[] = []
|
|
120
|
-
private hairOutlineDraws: DrawCall[] = []
|
|
121
|
-
private transparentOutlineDraws: DrawCall[] = []
|
|
122
|
-
|
|
123
|
-
private lastFpsUpdate = performance.now()
|
|
124
|
-
private framesSinceLastUpdate = 0
|
|
125
|
-
private frameTimeSamples: number[] = []
|
|
126
|
-
private frameTimeSum: number = 0
|
|
127
|
-
private drawCallCount: number = 0
|
|
128
|
-
private lastFrameTime = performance.now()
|
|
129
|
-
private stats: EngineStats = {
|
|
130
|
-
fps: 0,
|
|
131
|
-
frameTime: 0,
|
|
132
|
-
gpuMemory: 0,
|
|
133
|
-
}
|
|
134
|
-
private animationFrameId: number | null = null
|
|
135
|
-
private renderLoopCallback: (() => void) | null = null
|
|
136
|
-
|
|
137
|
-
private animationFrames: VMDKeyFrame[] = []
|
|
138
|
-
private animationTimeouts: number[] = []
|
|
139
|
-
private gpuMemoryMB: number = 0
|
|
140
|
-
private hasAnimation = false // Set to true when loadAnimation is called
|
|
141
|
-
private playingAnimation = false // Set to true when playAnimation is called
|
|
142
|
-
|
|
143
|
-
constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
|
|
144
|
-
this.canvas = canvas
|
|
145
|
-
if (options) {
|
|
146
|
-
this.ambient = options.ambient ?? 1.0
|
|
147
|
-
this.bloomIntensity = options.bloomIntensity ?? 0.12
|
|
148
|
-
this.rimLightIntensity = options.rimLightIntensity ?? 0.45
|
|
149
|
-
this.cameraDistance = options.cameraDistance ?? 26.6
|
|
150
|
-
this.cameraTarget = options.cameraTarget ?? new Vec3(0, 12.5, 0)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Step 1: Get WebGPU device and context
|
|
155
|
-
public async init() {
|
|
156
|
-
const adapter = await navigator.gpu?.requestAdapter()
|
|
157
|
-
const device = await adapter?.requestDevice()
|
|
158
|
-
if (!device) {
|
|
159
|
-
throw new Error("WebGPU is not supported in this browser.")
|
|
160
|
-
}
|
|
161
|
-
this.device = device
|
|
162
|
-
|
|
163
|
-
const context = this.canvas.getContext("webgpu")
|
|
164
|
-
if (!context) {
|
|
165
|
-
throw new Error("Failed to get WebGPU context.")
|
|
166
|
-
}
|
|
167
|
-
this.context = context
|
|
168
|
-
|
|
169
|
-
this.presentationFormat = navigator.gpu.getPreferredCanvasFormat()
|
|
170
|
-
|
|
171
|
-
this.context.configure({
|
|
172
|
-
device: this.device,
|
|
173
|
-
format: this.presentationFormat,
|
|
174
|
-
alphaMode: "premultiplied",
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
this.setupCamera()
|
|
178
|
-
this.setupLighting()
|
|
179
|
-
this.createPipelines()
|
|
180
|
-
this.createFullscreenQuad()
|
|
181
|
-
this.createBloomPipelines()
|
|
182
|
-
this.setupResize()
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private createPipelines() {
|
|
186
|
-
this.materialSampler = this.device.createSampler({
|
|
187
|
-
magFilter: "linear",
|
|
188
|
-
minFilter: "linear",
|
|
189
|
-
addressModeU: "repeat",
|
|
190
|
-
addressModeV: "repeat",
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
const shaderModule = this.device.createShaderModule({
|
|
194
|
-
label: "model shaders",
|
|
195
|
-
code: /* wgsl */ `
|
|
196
|
-
struct CameraUniforms {
|
|
197
|
-
view: mat4x4f,
|
|
198
|
-
projection: mat4x4f,
|
|
199
|
-
viewPos: vec3f,
|
|
200
|
-
_padding: f32,
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
struct Light {
|
|
204
|
-
direction: vec3f,
|
|
205
|
-
_padding1: f32,
|
|
206
|
-
color: vec3f,
|
|
207
|
-
intensity: f32,
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
struct LightUniforms {
|
|
211
|
-
ambient: f32,
|
|
212
|
-
lightCount: f32,
|
|
213
|
-
_padding1: f32,
|
|
214
|
-
_padding2: f32,
|
|
215
|
-
lights: array<Light, 4>,
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
struct MaterialUniforms {
|
|
219
|
-
alpha: f32,
|
|
220
|
-
alphaMultiplier: f32,
|
|
221
|
-
rimIntensity: f32,
|
|
222
|
-
_padding1: f32,
|
|
223
|
-
rimColor: vec3f,
|
|
224
|
-
isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
struct VertexOutput {
|
|
228
|
-
@builtin(position) position: vec4f,
|
|
229
|
-
@location(0) normal: vec3f,
|
|
230
|
-
@location(1) uv: vec2f,
|
|
231
|
-
@location(2) worldPos: vec3f,
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
235
|
-
@group(0) @binding(1) var<uniform> light: LightUniforms;
|
|
236
|
-
@group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
|
|
237
|
-
@group(0) @binding(3) var diffuseSampler: sampler;
|
|
238
|
-
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
239
|
-
@group(0) @binding(5) var toonTexture: texture_2d<f32>;
|
|
240
|
-
@group(0) @binding(6) var toonSampler: sampler;
|
|
241
|
-
@group(0) @binding(7) var<uniform> material: MaterialUniforms;
|
|
242
|
-
|
|
243
|
-
@vertex fn vs(
|
|
244
|
-
@location(0) position: vec3f,
|
|
245
|
-
@location(1) normal: vec3f,
|
|
246
|
-
@location(2) uv: vec2f,
|
|
247
|
-
@location(3) joints0: vec4<u32>,
|
|
248
|
-
@location(4) weights0: vec4<f32>
|
|
249
|
-
) -> VertexOutput {
|
|
250
|
-
var output: VertexOutput;
|
|
251
|
-
let pos4 = vec4f(position, 1.0);
|
|
252
|
-
|
|
253
|
-
// Branchless weight normalization (avoids GPU branch divergence)
|
|
254
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
255
|
-
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
256
|
-
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
257
|
-
|
|
258
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
259
|
-
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
260
|
-
for (var i = 0u; i < 4u; i++) {
|
|
261
|
-
let j = joints0[i];
|
|
262
|
-
let w = normalizedWeights[i];
|
|
263
|
-
let m = skinMats[j];
|
|
264
|
-
skinnedPos += (m * pos4) * w;
|
|
265
|
-
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
266
|
-
skinnedNrm += (r3 * normal) * w;
|
|
267
|
-
}
|
|
268
|
-
let worldPos = skinnedPos.xyz;
|
|
269
|
-
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
270
|
-
output.normal = normalize(skinnedNrm);
|
|
271
|
-
output.uv = uv;
|
|
272
|
-
output.worldPos = worldPos;
|
|
273
|
-
return output;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
277
|
-
// Early alpha test - discard before expensive calculations
|
|
278
|
-
var finalAlpha = material.alpha * material.alphaMultiplier;
|
|
279
|
-
if (material.isOverEyes > 0.5) {
|
|
280
|
-
finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
|
|
281
|
-
}
|
|
282
|
-
if (finalAlpha < 0.001) {
|
|
283
|
-
discard;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
let n = normalize(input.normal);
|
|
287
|
-
let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
288
|
-
|
|
289
|
-
var lightAccum = vec3f(light.ambient);
|
|
290
|
-
let numLights = u32(light.lightCount);
|
|
291
|
-
for (var i = 0u; i < numLights; i++) {
|
|
292
|
-
let l = -light.lights[i].direction;
|
|
293
|
-
let nDotL = max(dot(n, l), 0.0);
|
|
294
|
-
let toonUV = vec2f(nDotL, 0.5);
|
|
295
|
-
let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
|
|
296
|
-
let radiance = light.lights[i].color * light.lights[i].intensity;
|
|
297
|
-
lightAccum += toonFactor * radiance * nDotL;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Rim light calculation
|
|
301
|
-
let viewDir = normalize(camera.viewPos - input.worldPos);
|
|
302
|
-
var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
|
|
303
|
-
rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
|
|
304
|
-
let rimLight = material.rimColor * material.rimIntensity * rimFactor;
|
|
305
|
-
|
|
306
|
-
let color = albedo * lightAccum + rimLight;
|
|
307
|
-
|
|
308
|
-
return vec4f(color, finalAlpha);
|
|
309
|
-
}
|
|
310
|
-
`,
|
|
311
|
-
})
|
|
312
|
-
|
|
313
|
-
// Create explicit bind group layout for all pipelines using the main shader
|
|
314
|
-
this.mainBindGroupLayout = this.device.createBindGroupLayout({
|
|
315
|
-
label: "main material bind group layout",
|
|
316
|
-
entries: [
|
|
317
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
|
|
318
|
-
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
|
|
319
|
-
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
|
|
320
|
-
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
|
|
321
|
-
{ binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
|
|
322
|
-
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // toonTexture
|
|
323
|
-
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // toonSampler
|
|
324
|
-
{ binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
|
|
325
|
-
],
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
const mainPipelineLayout = this.device.createPipelineLayout({
|
|
329
|
-
label: "main pipeline layout",
|
|
330
|
-
bindGroupLayouts: [this.mainBindGroupLayout],
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
this.modelPipeline = this.device.createRenderPipeline({
|
|
334
|
-
label: "model pipeline",
|
|
335
|
-
layout: mainPipelineLayout,
|
|
336
|
-
vertex: {
|
|
337
|
-
module: shaderModule,
|
|
338
|
-
buffers: [
|
|
339
|
-
{
|
|
340
|
-
arrayStride: 8 * 4,
|
|
341
|
-
attributes: [
|
|
342
|
-
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
343
|
-
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
344
|
-
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
|
|
345
|
-
],
|
|
346
|
-
},
|
|
347
|
-
{
|
|
348
|
-
arrayStride: 4 * 2,
|
|
349
|
-
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
350
|
-
},
|
|
351
|
-
{
|
|
352
|
-
arrayStride: 4,
|
|
353
|
-
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
354
|
-
},
|
|
355
|
-
],
|
|
356
|
-
},
|
|
357
|
-
fragment: {
|
|
358
|
-
module: shaderModule,
|
|
359
|
-
targets: [
|
|
360
|
-
{
|
|
361
|
-
format: this.presentationFormat,
|
|
362
|
-
blend: {
|
|
363
|
-
color: {
|
|
364
|
-
srcFactor: "src-alpha",
|
|
365
|
-
dstFactor: "one-minus-src-alpha",
|
|
366
|
-
operation: "add",
|
|
367
|
-
},
|
|
368
|
-
alpha: {
|
|
369
|
-
srcFactor: "one",
|
|
370
|
-
dstFactor: "one-minus-src-alpha",
|
|
371
|
-
operation: "add",
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
},
|
|
375
|
-
],
|
|
376
|
-
},
|
|
377
|
-
primitive: { cullMode: "none" },
|
|
378
|
-
depthStencil: {
|
|
379
|
-
format: "depth24plus-stencil8",
|
|
380
|
-
depthWriteEnabled: true,
|
|
381
|
-
depthCompare: "less-equal",
|
|
382
|
-
},
|
|
383
|
-
multisample: {
|
|
384
|
-
count: this.sampleCount,
|
|
385
|
-
},
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
// Create bind group layout for outline pipelines
|
|
389
|
-
this.outlineBindGroupLayout = this.device.createBindGroupLayout({
|
|
390
|
-
label: "outline bind group layout",
|
|
391
|
-
entries: [
|
|
392
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
|
|
393
|
-
{ binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
|
|
394
|
-
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
|
|
395
|
-
],
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
const outlinePipelineLayout = this.device.createPipelineLayout({
|
|
399
|
-
label: "outline pipeline layout",
|
|
400
|
-
bindGroupLayouts: [this.outlineBindGroupLayout],
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
const outlineShaderModule = this.device.createShaderModule({
|
|
404
|
-
label: "outline shaders",
|
|
405
|
-
code: /* wgsl */ `
|
|
406
|
-
struct CameraUniforms {
|
|
407
|
-
view: mat4x4f,
|
|
408
|
-
projection: mat4x4f,
|
|
409
|
-
viewPos: vec3f,
|
|
410
|
-
_padding: f32,
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
struct MaterialUniforms {
|
|
414
|
-
edgeColor: vec4f,
|
|
415
|
-
edgeSize: f32,
|
|
416
|
-
isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
|
|
417
|
-
_padding1: f32,
|
|
418
|
-
_padding2: f32,
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
422
|
-
@group(0) @binding(1) var<uniform> material: MaterialUniforms;
|
|
423
|
-
@group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
|
|
424
|
-
|
|
425
|
-
struct VertexOutput {
|
|
426
|
-
@builtin(position) position: vec4f,
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
@vertex fn vs(
|
|
430
|
-
@location(0) position: vec3f,
|
|
431
|
-
@location(1) normal: vec3f,
|
|
432
|
-
@location(3) joints0: vec4<u32>,
|
|
433
|
-
@location(4) weights0: vec4<f32>
|
|
434
|
-
) -> VertexOutput {
|
|
435
|
-
var output: VertexOutput;
|
|
436
|
-
let pos4 = vec4f(position, 1.0);
|
|
437
|
-
|
|
438
|
-
// Branchless weight normalization (avoids GPU branch divergence)
|
|
439
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
440
|
-
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
441
|
-
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
442
|
-
|
|
443
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
444
|
-
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
445
|
-
for (var i = 0u; i < 4u; i++) {
|
|
446
|
-
let j = joints0[i];
|
|
447
|
-
let w = normalizedWeights[i];
|
|
448
|
-
let m = skinMats[j];
|
|
449
|
-
skinnedPos += (m * pos4) * w;
|
|
450
|
-
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
451
|
-
skinnedNrm += (r3 * normal) * w;
|
|
452
|
-
}
|
|
453
|
-
let worldPos = skinnedPos.xyz;
|
|
454
|
-
let worldNormal = normalize(skinnedNrm);
|
|
455
|
-
|
|
456
|
-
// MMD invert hull: expand vertices outward along normals
|
|
457
|
-
let scaleFactor = 0.01;
|
|
458
|
-
let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
|
|
459
|
-
output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
|
|
460
|
-
return output;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
@fragment fn fs() -> @location(0) vec4f {
|
|
464
|
-
var color = material.edgeColor;
|
|
465
|
-
|
|
466
|
-
if (material.isOverEyes > 0.5) {
|
|
467
|
-
color.a *= 0.5; // Hair outlines over eyes get 50% alpha
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
return color;
|
|
471
|
-
}
|
|
472
|
-
`,
|
|
473
|
-
})
|
|
474
|
-
|
|
475
|
-
this.outlinePipeline = this.device.createRenderPipeline({
|
|
476
|
-
label: "outline pipeline",
|
|
477
|
-
layout: outlinePipelineLayout,
|
|
478
|
-
vertex: {
|
|
479
|
-
module: outlineShaderModule,
|
|
480
|
-
buffers: [
|
|
481
|
-
{
|
|
482
|
-
arrayStride: 8 * 4,
|
|
483
|
-
attributes: [
|
|
484
|
-
{
|
|
485
|
-
shaderLocation: 0,
|
|
486
|
-
offset: 0,
|
|
487
|
-
format: "float32x3" as GPUVertexFormat,
|
|
488
|
-
},
|
|
489
|
-
{
|
|
490
|
-
shaderLocation: 1,
|
|
491
|
-
offset: 3 * 4,
|
|
492
|
-
format: "float32x3" as GPUVertexFormat,
|
|
493
|
-
},
|
|
494
|
-
],
|
|
495
|
-
},
|
|
496
|
-
{
|
|
497
|
-
arrayStride: 4 * 2,
|
|
498
|
-
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
499
|
-
},
|
|
500
|
-
{
|
|
501
|
-
arrayStride: 4,
|
|
502
|
-
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
503
|
-
},
|
|
504
|
-
],
|
|
505
|
-
},
|
|
506
|
-
fragment: {
|
|
507
|
-
module: outlineShaderModule,
|
|
508
|
-
targets: [
|
|
509
|
-
{
|
|
510
|
-
format: this.presentationFormat,
|
|
511
|
-
blend: {
|
|
512
|
-
color: {
|
|
513
|
-
srcFactor: "src-alpha",
|
|
514
|
-
dstFactor: "one-minus-src-alpha",
|
|
515
|
-
operation: "add",
|
|
516
|
-
},
|
|
517
|
-
alpha: {
|
|
518
|
-
srcFactor: "one",
|
|
519
|
-
dstFactor: "one-minus-src-alpha",
|
|
520
|
-
operation: "add",
|
|
521
|
-
},
|
|
522
|
-
},
|
|
523
|
-
},
|
|
524
|
-
],
|
|
525
|
-
},
|
|
526
|
-
primitive: {
|
|
527
|
-
cullMode: "back",
|
|
528
|
-
},
|
|
529
|
-
depthStencil: {
|
|
530
|
-
format: "depth24plus-stencil8",
|
|
531
|
-
depthWriteEnabled: true,
|
|
532
|
-
depthCompare: "less-equal",
|
|
533
|
-
},
|
|
534
|
-
multisample: {
|
|
535
|
-
count: this.sampleCount,
|
|
536
|
-
},
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
// Hair outline pipeline
|
|
540
|
-
this.hairOutlinePipeline = this.device.createRenderPipeline({
|
|
541
|
-
label: "hair outline pipeline",
|
|
542
|
-
layout: outlinePipelineLayout,
|
|
543
|
-
vertex: {
|
|
544
|
-
module: outlineShaderModule,
|
|
545
|
-
buffers: [
|
|
546
|
-
{
|
|
547
|
-
arrayStride: 8 * 4,
|
|
548
|
-
attributes: [
|
|
549
|
-
{
|
|
550
|
-
shaderLocation: 0,
|
|
551
|
-
offset: 0,
|
|
552
|
-
format: "float32x3" as GPUVertexFormat,
|
|
553
|
-
},
|
|
554
|
-
{
|
|
555
|
-
shaderLocation: 1,
|
|
556
|
-
offset: 3 * 4,
|
|
557
|
-
format: "float32x3" as GPUVertexFormat,
|
|
558
|
-
},
|
|
559
|
-
],
|
|
560
|
-
},
|
|
561
|
-
{
|
|
562
|
-
arrayStride: 4 * 2,
|
|
563
|
-
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
564
|
-
},
|
|
565
|
-
{
|
|
566
|
-
arrayStride: 4,
|
|
567
|
-
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
568
|
-
},
|
|
569
|
-
],
|
|
570
|
-
},
|
|
571
|
-
fragment: {
|
|
572
|
-
module: outlineShaderModule,
|
|
573
|
-
targets: [
|
|
574
|
-
{
|
|
575
|
-
format: this.presentationFormat,
|
|
576
|
-
blend: {
|
|
577
|
-
color: {
|
|
578
|
-
srcFactor: "src-alpha",
|
|
579
|
-
dstFactor: "one-minus-src-alpha",
|
|
580
|
-
operation: "add",
|
|
581
|
-
},
|
|
582
|
-
alpha: {
|
|
583
|
-
srcFactor: "one",
|
|
584
|
-
dstFactor: "one-minus-src-alpha",
|
|
585
|
-
operation: "add",
|
|
586
|
-
},
|
|
587
|
-
},
|
|
588
|
-
},
|
|
589
|
-
],
|
|
590
|
-
},
|
|
591
|
-
primitive: {
|
|
592
|
-
cullMode: "back",
|
|
593
|
-
},
|
|
594
|
-
depthStencil: {
|
|
595
|
-
format: "depth24plus-stencil8",
|
|
596
|
-
depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
|
|
597
|
-
depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
|
|
598
|
-
depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
|
|
599
|
-
depthBiasSlopeScale: 0.0,
|
|
600
|
-
depthBiasClamp: 0.0,
|
|
601
|
-
},
|
|
602
|
-
multisample: {
|
|
603
|
-
count: this.sampleCount,
|
|
604
|
-
},
|
|
605
|
-
})
|
|
606
|
-
|
|
607
|
-
// Eye overlay pipeline (renders after opaque, writes stencil)
|
|
608
|
-
this.eyePipeline = this.device.createRenderPipeline({
|
|
609
|
-
label: "eye overlay pipeline",
|
|
610
|
-
layout: mainPipelineLayout,
|
|
611
|
-
vertex: {
|
|
612
|
-
module: shaderModule,
|
|
613
|
-
buffers: [
|
|
614
|
-
{
|
|
615
|
-
arrayStride: 8 * 4,
|
|
616
|
-
attributes: [
|
|
617
|
-
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
618
|
-
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
619
|
-
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
|
|
620
|
-
],
|
|
621
|
-
},
|
|
622
|
-
{
|
|
623
|
-
arrayStride: 4 * 2,
|
|
624
|
-
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
625
|
-
},
|
|
626
|
-
{
|
|
627
|
-
arrayStride: 4,
|
|
628
|
-
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
629
|
-
},
|
|
630
|
-
],
|
|
631
|
-
},
|
|
632
|
-
fragment: {
|
|
633
|
-
module: shaderModule,
|
|
634
|
-
targets: [
|
|
635
|
-
{
|
|
636
|
-
format: this.presentationFormat,
|
|
637
|
-
blend: {
|
|
638
|
-
color: {
|
|
639
|
-
srcFactor: "src-alpha",
|
|
640
|
-
dstFactor: "one-minus-src-alpha",
|
|
641
|
-
operation: "add",
|
|
642
|
-
},
|
|
643
|
-
alpha: {
|
|
644
|
-
srcFactor: "one",
|
|
645
|
-
dstFactor: "one-minus-src-alpha",
|
|
646
|
-
operation: "add",
|
|
647
|
-
},
|
|
648
|
-
},
|
|
649
|
-
},
|
|
650
|
-
],
|
|
651
|
-
},
|
|
652
|
-
primitive: { cullMode: "front" },
|
|
653
|
-
depthStencil: {
|
|
654
|
-
format: "depth24plus-stencil8",
|
|
655
|
-
depthWriteEnabled: true, // Write depth to occlude back of head
|
|
656
|
-
depthCompare: "less-equal", // More lenient to reduce precision conflicts
|
|
657
|
-
depthBias: -0.00005, // Reduced bias to minimize conflicts while still occluding back face
|
|
658
|
-
depthBiasSlopeScale: 0.0,
|
|
659
|
-
depthBiasClamp: 0.0,
|
|
660
|
-
stencilFront: {
|
|
661
|
-
compare: "always",
|
|
662
|
-
failOp: "keep",
|
|
663
|
-
depthFailOp: "keep",
|
|
664
|
-
passOp: "replace", // Write stencil value 1
|
|
665
|
-
},
|
|
666
|
-
stencilBack: {
|
|
667
|
-
compare: "always",
|
|
668
|
-
failOp: "keep",
|
|
669
|
-
depthFailOp: "keep",
|
|
670
|
-
passOp: "replace",
|
|
671
|
-
},
|
|
672
|
-
},
|
|
673
|
-
multisample: { count: this.sampleCount },
|
|
674
|
-
})
|
|
675
|
-
|
|
676
|
-
// Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
|
|
677
|
-
const depthOnlyShaderModule = this.device.createShaderModule({
|
|
678
|
-
label: "depth only shader",
|
|
679
|
-
code: /* wgsl */ `
|
|
680
|
-
struct CameraUniforms {
|
|
681
|
-
view: mat4x4f,
|
|
682
|
-
projection: mat4x4f,
|
|
683
|
-
viewPos: vec3f,
|
|
684
|
-
_padding: f32,
|
|
685
|
-
};
|
|
686
|
-
|
|
687
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
688
|
-
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
689
|
-
|
|
690
|
-
@vertex fn vs(
|
|
691
|
-
@location(0) position: vec3f,
|
|
692
|
-
@location(1) normal: vec3f,
|
|
693
|
-
@location(3) joints0: vec4<u32>,
|
|
694
|
-
@location(4) weights0: vec4<f32>
|
|
695
|
-
) -> @builtin(position) vec4f {
|
|
696
|
-
let pos4 = vec4f(position, 1.0);
|
|
697
|
-
|
|
698
|
-
// Branchless weight normalization (avoids GPU branch divergence)
|
|
699
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
700
|
-
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
701
|
-
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
702
|
-
|
|
703
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
704
|
-
for (var i = 0u; i < 4u; i++) {
|
|
705
|
-
let j = joints0[i];
|
|
706
|
-
let w = normalizedWeights[i];
|
|
707
|
-
let m = skinMats[j];
|
|
708
|
-
skinnedPos += (m * pos4) * w;
|
|
709
|
-
}
|
|
710
|
-
let worldPos = skinnedPos.xyz;
|
|
711
|
-
let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
712
|
-
return clipPos;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
@fragment fn fs() -> @location(0) vec4f {
|
|
716
|
-
return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
|
|
717
|
-
}
|
|
718
|
-
`,
|
|
719
|
-
})
|
|
720
|
-
|
|
721
|
-
// Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
|
|
722
|
-
this.hairDepthPipeline = this.device.createRenderPipeline({
|
|
723
|
-
label: "hair depth pre-pass",
|
|
724
|
-
layout: mainPipelineLayout,
|
|
725
|
-
vertex: {
|
|
726
|
-
module: depthOnlyShaderModule,
|
|
727
|
-
buffers: [
|
|
728
|
-
{
|
|
729
|
-
arrayStride: 8 * 4,
|
|
730
|
-
attributes: [
|
|
731
|
-
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
732
|
-
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
733
|
-
],
|
|
734
|
-
},
|
|
735
|
-
{
|
|
736
|
-
arrayStride: 4 * 2,
|
|
737
|
-
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
738
|
-
},
|
|
739
|
-
{
|
|
740
|
-
arrayStride: 4,
|
|
741
|
-
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
742
|
-
},
|
|
743
|
-
],
|
|
744
|
-
},
|
|
745
|
-
fragment: {
|
|
746
|
-
module: depthOnlyShaderModule,
|
|
747
|
-
entryPoint: "fs",
|
|
748
|
-
targets: [
|
|
749
|
-
{
|
|
750
|
-
format: this.presentationFormat,
|
|
751
|
-
writeMask: 0, // Disable all color writes - we only care about depth
|
|
752
|
-
},
|
|
753
|
-
],
|
|
754
|
-
},
|
|
755
|
-
primitive: { cullMode: "front" },
|
|
756
|
-
depthStencil: {
|
|
757
|
-
format: "depth24plus-stencil8",
|
|
758
|
-
depthWriteEnabled: true,
|
|
759
|
-
depthCompare: "less-equal", // Match the color pass compare mode for consistency
|
|
760
|
-
depthBias: 0.0,
|
|
761
|
-
depthBiasSlopeScale: 0.0,
|
|
762
|
-
depthBiasClamp: 0.0,
|
|
763
|
-
},
|
|
764
|
-
multisample: { count: this.sampleCount },
|
|
765
|
-
})
|
|
766
|
-
|
|
767
|
-
// Hair pipeline for rendering over eyes (stencil == 1)
|
|
768
|
-
this.hairPipelineOverEyes = this.device.createRenderPipeline({
|
|
769
|
-
label: "hair pipeline (over eyes)",
|
|
770
|
-
layout: mainPipelineLayout,
|
|
771
|
-
vertex: {
|
|
772
|
-
module: shaderModule,
|
|
773
|
-
buffers: [
|
|
774
|
-
{
|
|
775
|
-
arrayStride: 8 * 4,
|
|
776
|
-
attributes: [
|
|
777
|
-
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
778
|
-
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
779
|
-
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
|
|
780
|
-
],
|
|
781
|
-
},
|
|
782
|
-
{
|
|
783
|
-
arrayStride: 4 * 2,
|
|
784
|
-
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
785
|
-
},
|
|
786
|
-
{
|
|
787
|
-
arrayStride: 4,
|
|
788
|
-
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
789
|
-
},
|
|
790
|
-
],
|
|
791
|
-
},
|
|
792
|
-
fragment: {
|
|
793
|
-
module: shaderModule,
|
|
794
|
-
targets: [
|
|
795
|
-
{
|
|
796
|
-
format: this.presentationFormat,
|
|
797
|
-
blend: {
|
|
798
|
-
color: {
|
|
799
|
-
srcFactor: "src-alpha",
|
|
800
|
-
dstFactor: "one-minus-src-alpha",
|
|
801
|
-
operation: "add",
|
|
802
|
-
},
|
|
803
|
-
alpha: {
|
|
804
|
-
srcFactor: "one",
|
|
805
|
-
dstFactor: "one-minus-src-alpha",
|
|
806
|
-
operation: "add",
|
|
807
|
-
},
|
|
808
|
-
},
|
|
809
|
-
},
|
|
810
|
-
],
|
|
811
|
-
},
|
|
812
|
-
primitive: { cullMode: "front" },
|
|
813
|
-
depthStencil: {
|
|
814
|
-
format: "depth24plus-stencil8",
|
|
815
|
-
depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
|
|
816
|
-
depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
|
|
817
|
-
stencilFront: {
|
|
818
|
-
compare: "equal", // Only render where stencil == 1 (over eyes)
|
|
819
|
-
failOp: "keep",
|
|
820
|
-
depthFailOp: "keep",
|
|
821
|
-
passOp: "keep",
|
|
822
|
-
},
|
|
823
|
-
stencilBack: {
|
|
824
|
-
compare: "equal",
|
|
825
|
-
failOp: "keep",
|
|
826
|
-
depthFailOp: "keep",
|
|
827
|
-
passOp: "keep",
|
|
828
|
-
},
|
|
829
|
-
},
|
|
830
|
-
multisample: { count: this.sampleCount },
|
|
831
|
-
})
|
|
832
|
-
|
|
833
|
-
// Hair pipeline for rendering over non-eyes (stencil != 1)
|
|
834
|
-
this.hairPipelineOverNonEyes = this.device.createRenderPipeline({
|
|
835
|
-
label: "hair pipeline (over non-eyes)",
|
|
836
|
-
layout: mainPipelineLayout,
|
|
837
|
-
vertex: {
|
|
838
|
-
module: shaderModule,
|
|
839
|
-
buffers: [
|
|
840
|
-
{
|
|
841
|
-
arrayStride: 8 * 4,
|
|
842
|
-
attributes: [
|
|
843
|
-
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
844
|
-
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
845
|
-
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
|
|
846
|
-
],
|
|
847
|
-
},
|
|
848
|
-
{
|
|
849
|
-
arrayStride: 4 * 2,
|
|
850
|
-
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
851
|
-
},
|
|
852
|
-
{
|
|
853
|
-
arrayStride: 4,
|
|
854
|
-
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
855
|
-
},
|
|
856
|
-
],
|
|
857
|
-
},
|
|
858
|
-
fragment: {
|
|
859
|
-
module: shaderModule,
|
|
860
|
-
targets: [
|
|
861
|
-
{
|
|
862
|
-
format: this.presentationFormat,
|
|
863
|
-
blend: {
|
|
864
|
-
color: {
|
|
865
|
-
srcFactor: "src-alpha",
|
|
866
|
-
dstFactor: "one-minus-src-alpha",
|
|
867
|
-
operation: "add",
|
|
868
|
-
},
|
|
869
|
-
alpha: {
|
|
870
|
-
srcFactor: "one",
|
|
871
|
-
dstFactor: "one-minus-src-alpha",
|
|
872
|
-
operation: "add",
|
|
873
|
-
},
|
|
874
|
-
},
|
|
875
|
-
},
|
|
876
|
-
],
|
|
877
|
-
},
|
|
878
|
-
primitive: { cullMode: "front" },
|
|
879
|
-
depthStencil: {
|
|
880
|
-
format: "depth24plus-stencil8",
|
|
881
|
-
depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
|
|
882
|
-
depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
|
|
883
|
-
stencilFront: {
|
|
884
|
-
compare: "not-equal", // Only render where stencil != 1 (over non-eyes)
|
|
885
|
-
failOp: "keep",
|
|
886
|
-
depthFailOp: "keep",
|
|
887
|
-
passOp: "keep",
|
|
888
|
-
},
|
|
889
|
-
stencilBack: {
|
|
890
|
-
compare: "not-equal",
|
|
891
|
-
failOp: "keep",
|
|
892
|
-
depthFailOp: "keep",
|
|
893
|
-
passOp: "keep",
|
|
894
|
-
},
|
|
895
|
-
},
|
|
896
|
-
multisample: { count: this.sampleCount },
|
|
897
|
-
})
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Create compute shader for skin matrix computation
|
|
901
|
-
private createSkinMatrixComputePipeline() {
|
|
902
|
-
const computeShader = this.device.createShaderModule({
|
|
903
|
-
label: "skin matrix compute",
|
|
904
|
-
code: /* wgsl */ `
|
|
905
|
-
struct BoneCountUniform {
|
|
906
|
-
count: u32,
|
|
907
|
-
_padding1: u32,
|
|
908
|
-
_padding2: u32,
|
|
909
|
-
_padding3: u32,
|
|
910
|
-
_padding4: vec4<u32>,
|
|
911
|
-
};
|
|
912
|
-
|
|
913
|
-
@group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
|
|
914
|
-
@group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
|
|
915
|
-
@group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
|
|
916
|
-
@group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
|
|
917
|
-
|
|
918
|
-
@compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
|
|
919
|
-
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
|
920
|
-
let boneIndex = globalId.x;
|
|
921
|
-
if (boneIndex >= boneCount.count) {
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
924
|
-
let worldMat = worldMatrices[boneIndex];
|
|
925
|
-
let invBindMat = inverseBindMatrices[boneIndex];
|
|
926
|
-
skinMatrices[boneIndex] = worldMat * invBindMat;
|
|
927
|
-
}
|
|
928
|
-
`,
|
|
929
|
-
})
|
|
930
|
-
|
|
931
|
-
this.skinMatrixComputePipeline = this.device.createComputePipeline({
|
|
932
|
-
label: "skin matrix compute pipeline",
|
|
933
|
-
layout: "auto",
|
|
934
|
-
compute: {
|
|
935
|
-
module: computeShader,
|
|
936
|
-
},
|
|
937
|
-
})
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
// Create fullscreen quad for post-processing
|
|
941
|
-
private createFullscreenQuad() {
|
|
942
|
-
// Fullscreen quad vertices: two triangles covering the entire screen - Format: position (x, y), uv (u, v)
|
|
943
|
-
const quadVertices = new Float32Array([
|
|
944
|
-
// Triangle 1
|
|
945
|
-
-1.0,
|
|
946
|
-
-1.0,
|
|
947
|
-
0.0,
|
|
948
|
-
0.0, // bottom-left
|
|
949
|
-
1.0,
|
|
950
|
-
-1.0,
|
|
951
|
-
1.0,
|
|
952
|
-
0.0, // bottom-right
|
|
953
|
-
-1.0,
|
|
954
|
-
1.0,
|
|
955
|
-
0.0,
|
|
956
|
-
1.0, // top-left
|
|
957
|
-
// Triangle 2
|
|
958
|
-
-1.0,
|
|
959
|
-
1.0,
|
|
960
|
-
0.0,
|
|
961
|
-
1.0, // top-left
|
|
962
|
-
1.0,
|
|
963
|
-
-1.0,
|
|
964
|
-
1.0,
|
|
965
|
-
0.0, // bottom-right
|
|
966
|
-
1.0,
|
|
967
|
-
1.0,
|
|
968
|
-
1.0,
|
|
969
|
-
1.0, // top-right
|
|
970
|
-
])
|
|
971
|
-
|
|
972
|
-
this.fullscreenQuadBuffer = this.device.createBuffer({
|
|
973
|
-
label: "fullscreen quad",
|
|
974
|
-
size: quadVertices.byteLength,
|
|
975
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
976
|
-
})
|
|
977
|
-
this.device.queue.writeBuffer(this.fullscreenQuadBuffer, 0, quadVertices)
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
// Create bloom post-processing pipelines
|
|
981
|
-
private createBloomPipelines() {
|
|
982
|
-
// Bloom extraction shader (extracts bright areas)
|
|
983
|
-
const bloomExtractShader = this.device.createShaderModule({
|
|
984
|
-
label: "bloom extract",
|
|
985
|
-
code: /* wgsl */ `
|
|
986
|
-
struct VertexOutput {
|
|
987
|
-
@builtin(position) position: vec4f,
|
|
988
|
-
@location(0) uv: vec2f,
|
|
989
|
-
};
|
|
990
|
-
|
|
991
|
-
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
992
|
-
var output: VertexOutput;
|
|
993
|
-
// Generate fullscreen quad from vertex index
|
|
994
|
-
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
995
|
-
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
996
|
-
output.position = vec4f(x, y, 0.0, 1.0);
|
|
997
|
-
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
998
|
-
return output;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
struct BloomExtractUniforms {
|
|
1002
|
-
threshold: f32,
|
|
1003
|
-
_padding1: f32,
|
|
1004
|
-
_padding2: f32,
|
|
1005
|
-
_padding3: f32,
|
|
1006
|
-
_padding4: f32,
|
|
1007
|
-
_padding5: f32,
|
|
1008
|
-
_padding6: f32,
|
|
1009
|
-
_padding7: f32,
|
|
1010
|
-
};
|
|
1011
|
-
|
|
1012
|
-
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
1013
|
-
@group(0) @binding(1) var inputSampler: sampler;
|
|
1014
|
-
@group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
|
|
1015
|
-
|
|
1016
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
1017
|
-
let color = textureSample(inputTexture, inputSampler, input.uv);
|
|
1018
|
-
// Extract bright areas above threshold
|
|
1019
|
-
let threshold = extractUniforms.threshold;
|
|
1020
|
-
let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
|
|
1021
|
-
return vec4f(bloom, color.a);
|
|
1022
|
-
}
|
|
1023
|
-
`,
|
|
1024
|
-
})
|
|
1025
|
-
|
|
1026
|
-
// Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
|
|
1027
|
-
const bloomBlurShader = this.device.createShaderModule({
|
|
1028
|
-
label: "bloom blur",
|
|
1029
|
-
code: /* wgsl */ `
|
|
1030
|
-
struct VertexOutput {
|
|
1031
|
-
@builtin(position) position: vec4f,
|
|
1032
|
-
@location(0) uv: vec2f,
|
|
1033
|
-
};
|
|
1034
|
-
|
|
1035
|
-
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
1036
|
-
var output: VertexOutput;
|
|
1037
|
-
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
1038
|
-
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
1039
|
-
output.position = vec4f(x, y, 0.0, 1.0);
|
|
1040
|
-
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
1041
|
-
return output;
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
struct BlurUniforms {
|
|
1045
|
-
direction: vec2f,
|
|
1046
|
-
_padding1: f32,
|
|
1047
|
-
_padding2: f32,
|
|
1048
|
-
_padding3: f32,
|
|
1049
|
-
_padding4: f32,
|
|
1050
|
-
_padding5: f32,
|
|
1051
|
-
_padding6: f32,
|
|
1052
|
-
};
|
|
1053
|
-
|
|
1054
|
-
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
1055
|
-
@group(0) @binding(1) var inputSampler: sampler;
|
|
1056
|
-
@group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
|
|
1057
|
-
|
|
1058
|
-
// 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
|
|
1059
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
1060
|
-
let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
|
|
1061
|
-
|
|
1062
|
-
// Bilinear optimization: leverage hardware filtering to sample between pixels
|
|
1063
|
-
// Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
|
|
1064
|
-
// Optimized 3-tap: combine adjacent samples using weighted offsets
|
|
1065
|
-
let weight0 = 0.38774; // Center sample
|
|
1066
|
-
let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
|
|
1067
|
-
let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
|
|
1068
|
-
|
|
1069
|
-
var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
|
|
1070
|
-
let offsetVec = offset1 * texelSize * blurUniforms.direction;
|
|
1071
|
-
result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
|
|
1072
|
-
result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
|
|
1073
|
-
|
|
1074
|
-
return result;
|
|
1075
|
-
}
|
|
1076
|
-
`,
|
|
1077
|
-
})
|
|
1078
|
-
|
|
1079
|
-
// Bloom composition shader (combines original scene with bloom)
|
|
1080
|
-
const bloomComposeShader = this.device.createShaderModule({
|
|
1081
|
-
label: "bloom compose",
|
|
1082
|
-
code: /* wgsl */ `
|
|
1083
|
-
struct VertexOutput {
|
|
1084
|
-
@builtin(position) position: vec4f,
|
|
1085
|
-
@location(0) uv: vec2f,
|
|
1086
|
-
};
|
|
1087
|
-
|
|
1088
|
-
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
1089
|
-
var output: VertexOutput;
|
|
1090
|
-
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
1091
|
-
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
1092
|
-
output.position = vec4f(x, y, 0.0, 1.0);
|
|
1093
|
-
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
1094
|
-
return output;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
struct BloomComposeUniforms {
|
|
1098
|
-
intensity: f32,
|
|
1099
|
-
_padding1: f32,
|
|
1100
|
-
_padding2: f32,
|
|
1101
|
-
_padding3: f32,
|
|
1102
|
-
_padding4: f32,
|
|
1103
|
-
_padding5: f32,
|
|
1104
|
-
_padding6: f32,
|
|
1105
|
-
_padding7: f32,
|
|
1106
|
-
};
|
|
1107
|
-
|
|
1108
|
-
@group(0) @binding(0) var sceneTexture: texture_2d<f32>;
|
|
1109
|
-
@group(0) @binding(1) var sceneSampler: sampler;
|
|
1110
|
-
@group(0) @binding(2) var bloomTexture: texture_2d<f32>;
|
|
1111
|
-
@group(0) @binding(3) var bloomSampler: sampler;
|
|
1112
|
-
@group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
|
|
1113
|
-
|
|
1114
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
1115
|
-
let scene = textureSample(sceneTexture, sceneSampler, input.uv);
|
|
1116
|
-
let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
|
|
1117
|
-
// Additive blending with intensity control
|
|
1118
|
-
let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
|
|
1119
|
-
return vec4f(result, scene.a);
|
|
1120
|
-
}
|
|
1121
|
-
`,
|
|
1122
|
-
})
|
|
1123
|
-
|
|
1124
|
-
// Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
|
|
1125
|
-
const blurDirectionBuffer = this.device.createBuffer({
|
|
1126
|
-
label: "blur direction",
|
|
1127
|
-
size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
|
|
1128
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1129
|
-
})
|
|
1130
|
-
|
|
1131
|
-
// Create uniform buffer for bloom intensity (minimum 32 bytes for WebGPU)
|
|
1132
|
-
const bloomIntensityBuffer = this.device.createBuffer({
|
|
1133
|
-
label: "bloom intensity",
|
|
1134
|
-
size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
|
|
1135
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1136
|
-
})
|
|
1137
|
-
|
|
1138
|
-
// Create uniform buffer for bloom threshold (minimum 32 bytes for WebGPU)
|
|
1139
|
-
const bloomThresholdBuffer = this.device.createBuffer({
|
|
1140
|
-
label: "bloom threshold",
|
|
1141
|
-
size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
|
|
1142
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1143
|
-
})
|
|
1144
|
-
|
|
1145
|
-
// Set default bloom values
|
|
1146
|
-
const intensityData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
|
|
1147
|
-
intensityData[0] = this.bloomIntensity
|
|
1148
|
-
this.device.queue.writeBuffer(bloomIntensityBuffer, 0, intensityData)
|
|
1149
|
-
|
|
1150
|
-
const thresholdData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
|
|
1151
|
-
thresholdData[0] = this.bloomThreshold
|
|
1152
|
-
this.device.queue.writeBuffer(bloomThresholdBuffer, 0, thresholdData)
|
|
1153
|
-
|
|
1154
|
-
// Create linear sampler for post-processing
|
|
1155
|
-
const linearSampler = this.device.createSampler({
|
|
1156
|
-
magFilter: "linear",
|
|
1157
|
-
minFilter: "linear",
|
|
1158
|
-
addressModeU: "clamp-to-edge",
|
|
1159
|
-
addressModeV: "clamp-to-edge",
|
|
1160
|
-
})
|
|
1161
|
-
|
|
1162
|
-
// Bloom extraction pipeline
|
|
1163
|
-
this.bloomExtractPipeline = this.device.createRenderPipeline({
|
|
1164
|
-
label: "bloom extract",
|
|
1165
|
-
layout: "auto",
|
|
1166
|
-
vertex: {
|
|
1167
|
-
module: bloomExtractShader,
|
|
1168
|
-
entryPoint: "vs",
|
|
1169
|
-
},
|
|
1170
|
-
fragment: {
|
|
1171
|
-
module: bloomExtractShader,
|
|
1172
|
-
entryPoint: "fs",
|
|
1173
|
-
targets: [{ format: this.presentationFormat }],
|
|
1174
|
-
},
|
|
1175
|
-
primitive: { topology: "triangle-list" },
|
|
1176
|
-
})
|
|
1177
|
-
|
|
1178
|
-
// Bloom blur pipeline
|
|
1179
|
-
this.bloomBlurPipeline = this.device.createRenderPipeline({
|
|
1180
|
-
label: "bloom blur",
|
|
1181
|
-
layout: "auto",
|
|
1182
|
-
vertex: {
|
|
1183
|
-
module: bloomBlurShader,
|
|
1184
|
-
entryPoint: "vs",
|
|
1185
|
-
},
|
|
1186
|
-
fragment: {
|
|
1187
|
-
module: bloomBlurShader,
|
|
1188
|
-
entryPoint: "fs",
|
|
1189
|
-
targets: [{ format: this.presentationFormat }],
|
|
1190
|
-
},
|
|
1191
|
-
primitive: { topology: "triangle-list" },
|
|
1192
|
-
})
|
|
1193
|
-
|
|
1194
|
-
// Bloom composition pipeline
|
|
1195
|
-
this.bloomComposePipeline = this.device.createRenderPipeline({
|
|
1196
|
-
label: "bloom compose",
|
|
1197
|
-
layout: "auto",
|
|
1198
|
-
vertex: {
|
|
1199
|
-
module: bloomComposeShader,
|
|
1200
|
-
entryPoint: "vs",
|
|
1201
|
-
},
|
|
1202
|
-
fragment: {
|
|
1203
|
-
module: bloomComposeShader,
|
|
1204
|
-
entryPoint: "fs",
|
|
1205
|
-
targets: [{ format: this.presentationFormat }],
|
|
1206
|
-
},
|
|
1207
|
-
primitive: { topology: "triangle-list" },
|
|
1208
|
-
})
|
|
1209
|
-
|
|
1210
|
-
// Store buffers and sampler for later use
|
|
1211
|
-
this.blurDirectionBuffer = blurDirectionBuffer
|
|
1212
|
-
this.bloomIntensityBuffer = bloomIntensityBuffer
|
|
1213
|
-
this.bloomThresholdBuffer = bloomThresholdBuffer
|
|
1214
|
-
this.linearSampler = linearSampler
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
private setupBloom(width: number, height: number) {
|
|
1218
|
-
const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR)
|
|
1219
|
-
const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR)
|
|
1220
|
-
this.bloomExtractTexture = this.device.createTexture({
|
|
1221
|
-
label: "bloom extract",
|
|
1222
|
-
size: [bloomWidth, bloomHeight],
|
|
1223
|
-
format: this.presentationFormat,
|
|
1224
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1225
|
-
})
|
|
1226
|
-
this.bloomBlurTexture1 = this.device.createTexture({
|
|
1227
|
-
label: "bloom blur 1",
|
|
1228
|
-
size: [bloomWidth, bloomHeight],
|
|
1229
|
-
format: this.presentationFormat,
|
|
1230
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1231
|
-
})
|
|
1232
|
-
this.bloomBlurTexture2 = this.device.createTexture({
|
|
1233
|
-
label: "bloom blur 2",
|
|
1234
|
-
size: [bloomWidth, bloomHeight],
|
|
1235
|
-
format: this.presentationFormat,
|
|
1236
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1237
|
-
})
|
|
1238
|
-
|
|
1239
|
-
// Create bloom bind groups
|
|
1240
|
-
this.bloomExtractBindGroup = this.device.createBindGroup({
|
|
1241
|
-
layout: this.bloomExtractPipeline.getBindGroupLayout(0),
|
|
1242
|
-
entries: [
|
|
1243
|
-
{ binding: 0, resource: this.sceneRenderTexture.createView() },
|
|
1244
|
-
{ binding: 1, resource: this.linearSampler },
|
|
1245
|
-
{ binding: 2, resource: { buffer: this.bloomThresholdBuffer } },
|
|
1246
|
-
],
|
|
1247
|
-
})
|
|
1248
|
-
|
|
1249
|
-
this.bloomBlurHBindGroup = this.device.createBindGroup({
|
|
1250
|
-
layout: this.bloomBlurPipeline.getBindGroupLayout(0),
|
|
1251
|
-
entries: [
|
|
1252
|
-
{ binding: 0, resource: this.bloomExtractTexture.createView() },
|
|
1253
|
-
{ binding: 1, resource: this.linearSampler },
|
|
1254
|
-
{ binding: 2, resource: { buffer: this.blurDirectionBuffer } },
|
|
1255
|
-
],
|
|
1256
|
-
})
|
|
1257
|
-
|
|
1258
|
-
this.bloomBlurVBindGroup = this.device.createBindGroup({
|
|
1259
|
-
layout: this.bloomBlurPipeline.getBindGroupLayout(0),
|
|
1260
|
-
entries: [
|
|
1261
|
-
{ binding: 0, resource: this.bloomBlurTexture1.createView() },
|
|
1262
|
-
{ binding: 1, resource: this.linearSampler },
|
|
1263
|
-
{ binding: 2, resource: { buffer: this.blurDirectionBuffer } },
|
|
1264
|
-
],
|
|
1265
|
-
})
|
|
1266
|
-
|
|
1267
|
-
this.bloomComposeBindGroup = this.device.createBindGroup({
|
|
1268
|
-
layout: this.bloomComposePipeline.getBindGroupLayout(0),
|
|
1269
|
-
entries: [
|
|
1270
|
-
{ binding: 0, resource: this.sceneRenderTexture.createView() },
|
|
1271
|
-
{ binding: 1, resource: this.linearSampler },
|
|
1272
|
-
{ binding: 2, resource: this.bloomBlurTexture2.createView() },
|
|
1273
|
-
{ binding: 3, resource: this.linearSampler },
|
|
1274
|
-
{ binding: 4, resource: { buffer: this.bloomIntensityBuffer } },
|
|
1275
|
-
],
|
|
1276
|
-
})
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
// Step 3: Setup canvas resize handling
|
|
1280
|
-
private setupResize() {
|
|
1281
|
-
this.resizeObserver = new ResizeObserver(() => this.handleResize())
|
|
1282
|
-
this.resizeObserver.observe(this.canvas)
|
|
1283
|
-
this.handleResize()
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
private handleResize() {
|
|
1287
|
-
const displayWidth = this.canvas.clientWidth
|
|
1288
|
-
const displayHeight = this.canvas.clientHeight
|
|
1289
|
-
|
|
1290
|
-
const dpr = window.devicePixelRatio || 1
|
|
1291
|
-
const width = Math.floor(displayWidth * dpr)
|
|
1292
|
-
const height = Math.floor(displayHeight * dpr)
|
|
1293
|
-
|
|
1294
|
-
if (!this.multisampleTexture || this.canvas.width !== width || this.canvas.height !== height) {
|
|
1295
|
-
this.canvas.width = width
|
|
1296
|
-
this.canvas.height = height
|
|
1297
|
-
|
|
1298
|
-
this.multisampleTexture = this.device.createTexture({
|
|
1299
|
-
label: "multisample render target",
|
|
1300
|
-
size: [width, height],
|
|
1301
|
-
sampleCount: this.sampleCount,
|
|
1302
|
-
format: this.presentationFormat,
|
|
1303
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1304
|
-
})
|
|
1305
|
-
|
|
1306
|
-
this.depthTexture = this.device.createTexture({
|
|
1307
|
-
label: "depth texture",
|
|
1308
|
-
size: [width, height],
|
|
1309
|
-
sampleCount: this.sampleCount,
|
|
1310
|
-
format: "depth24plus-stencil8",
|
|
1311
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1312
|
-
})
|
|
1313
|
-
|
|
1314
|
-
// Create scene render texture (non-multisampled for post-processing)
|
|
1315
|
-
this.sceneRenderTexture = this.device.createTexture({
|
|
1316
|
-
label: "scene render texture",
|
|
1317
|
-
size: [width, height],
|
|
1318
|
-
format: this.presentationFormat,
|
|
1319
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1320
|
-
})
|
|
1321
|
-
this.sceneRenderTextureView = this.sceneRenderTexture.createView()
|
|
1322
|
-
|
|
1323
|
-
// Setup bloom textures and bind groups
|
|
1324
|
-
this.setupBloom(width, height)
|
|
1325
|
-
|
|
1326
|
-
const depthTextureView = this.depthTexture.createView()
|
|
1327
|
-
|
|
1328
|
-
// Render scene to texture instead of directly to canvas
|
|
1329
|
-
const colorAttachment: GPURenderPassColorAttachment =
|
|
1330
|
-
this.sampleCount > 1
|
|
1331
|
-
? {
|
|
1332
|
-
view: this.multisampleTexture.createView(),
|
|
1333
|
-
resolveTarget: this.sceneRenderTextureView,
|
|
1334
|
-
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1335
|
-
loadOp: "clear",
|
|
1336
|
-
storeOp: "store",
|
|
1337
|
-
}
|
|
1338
|
-
: {
|
|
1339
|
-
view: this.sceneRenderTextureView,
|
|
1340
|
-
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1341
|
-
loadOp: "clear",
|
|
1342
|
-
storeOp: "store",
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
this.renderPassDescriptor = {
|
|
1346
|
-
label: "renderPass",
|
|
1347
|
-
colorAttachments: [colorAttachment],
|
|
1348
|
-
depthStencilAttachment: {
|
|
1349
|
-
view: depthTextureView,
|
|
1350
|
-
depthClearValue: 1.0,
|
|
1351
|
-
depthLoadOp: "clear",
|
|
1352
|
-
depthStoreOp: "store",
|
|
1353
|
-
stencilClearValue: 0,
|
|
1354
|
-
stencilLoadOp: "clear",
|
|
1355
|
-
stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
|
|
1356
|
-
},
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
this.camera.aspect = width / height
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
// Step 4: Create camera and uniform buffer
|
|
1364
|
-
private setupCamera() {
|
|
1365
|
-
this.cameraUniformBuffer = this.device.createBuffer({
|
|
1366
|
-
label: "camera uniforms",
|
|
1367
|
-
size: 40 * 4,
|
|
1368
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1369
|
-
})
|
|
1370
|
-
|
|
1371
|
-
this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraDistance, this.cameraTarget)
|
|
1372
|
-
|
|
1373
|
-
this.camera.aspect = this.canvas.width / this.canvas.height
|
|
1374
|
-
this.camera.attachControl(this.canvas)
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// Step 5: Create lighting buffers
|
|
1378
|
-
private setupLighting() {
|
|
1379
|
-
this.lightUniformBuffer = this.device.createBuffer({
|
|
1380
|
-
label: "light uniforms",
|
|
1381
|
-
size: 64 * 4,
|
|
1382
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1383
|
-
})
|
|
1384
|
-
|
|
1385
|
-
this.lightCount = 0
|
|
1386
|
-
|
|
1387
|
-
this.setAmbient(this.ambient)
|
|
1388
|
-
this.addLight(new Vec3(-0.5, -0.8, 0.5).normalize(), new Vec3(1.0, 0.95, 0.9), 0.02)
|
|
1389
|
-
this.addLight(new Vec3(0.7, -0.5, 0.3).normalize(), new Vec3(0.8, 0.85, 1.0), 0.015)
|
|
1390
|
-
this.addLight(new Vec3(0.3, -0.5, -1.0).normalize(), new Vec3(0.9, 0.9, 1.0), 0.01)
|
|
1391
|
-
this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
private addLight(direction: Vec3, color: Vec3, intensity: number = 1.0): boolean {
|
|
1395
|
-
if (this.lightCount >= 4) return false
|
|
1396
|
-
|
|
1397
|
-
const normalized = direction.normalize()
|
|
1398
|
-
const baseIndex = 4 + this.lightCount * 8
|
|
1399
|
-
this.lightData[baseIndex] = normalized.x
|
|
1400
|
-
this.lightData[baseIndex + 1] = normalized.y
|
|
1401
|
-
this.lightData[baseIndex + 2] = normalized.z
|
|
1402
|
-
this.lightData[baseIndex + 3] = 0
|
|
1403
|
-
this.lightData[baseIndex + 4] = color.x
|
|
1404
|
-
this.lightData[baseIndex + 5] = color.y
|
|
1405
|
-
this.lightData[baseIndex + 6] = color.z
|
|
1406
|
-
this.lightData[baseIndex + 7] = intensity
|
|
1407
|
-
|
|
1408
|
-
this.lightCount++
|
|
1409
|
-
this.lightData[1] = this.lightCount
|
|
1410
|
-
return true
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
private setAmbient(intensity: number) {
|
|
1414
|
-
this.lightData[0] = intensity
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
public async loadAnimation(url: string) {
|
|
1418
|
-
const frames = await VMDLoader.load(url)
|
|
1419
|
-
this.animationFrames = frames
|
|
1420
|
-
this.hasAnimation = true
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
public playAnimation() {
|
|
1424
|
-
if (this.animationFrames.length === 0) return
|
|
1425
|
-
|
|
1426
|
-
this.stopAnimation()
|
|
1427
|
-
this.playingAnimation = true
|
|
1428
|
-
|
|
1429
|
-
const allBoneKeyFrames: BoneKeyFrame[] = []
|
|
1430
|
-
for (const keyFrame of this.animationFrames) {
|
|
1431
|
-
for (const boneFrame of keyFrame.boneFrames) {
|
|
1432
|
-
allBoneKeyFrames.push({
|
|
1433
|
-
boneName: boneFrame.boneName,
|
|
1434
|
-
time: keyFrame.time,
|
|
1435
|
-
rotation: boneFrame.rotation,
|
|
1436
|
-
})
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
const boneKeyFramesByBone = new Map<string, BoneKeyFrame[]>()
|
|
1441
|
-
for (const boneKeyFrame of allBoneKeyFrames) {
|
|
1442
|
-
if (!boneKeyFramesByBone.has(boneKeyFrame.boneName)) {
|
|
1443
|
-
boneKeyFramesByBone.set(boneKeyFrame.boneName, [])
|
|
1444
|
-
}
|
|
1445
|
-
boneKeyFramesByBone.get(boneKeyFrame.boneName)!.push(boneKeyFrame)
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
for (const keyFrames of boneKeyFramesByBone.values()) {
|
|
1449
|
-
keyFrames.sort((a, b) => a.time - b.time)
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
const time0Rotations: Array<{ boneName: string; rotation: Quat }> = []
|
|
1453
|
-
const bonesWithTime0 = new Set<string>()
|
|
1454
|
-
for (const [boneName, keyFrames] of boneKeyFramesByBone.entries()) {
|
|
1455
|
-
if (keyFrames.length > 0 && keyFrames[0].time === 0) {
|
|
1456
|
-
time0Rotations.push({
|
|
1457
|
-
boneName: boneName,
|
|
1458
|
-
rotation: keyFrames[0].rotation,
|
|
1459
|
-
})
|
|
1460
|
-
bonesWithTime0.add(boneName)
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
if (this.currentModel) {
|
|
1465
|
-
if (time0Rotations.length > 0) {
|
|
1466
|
-
const boneNames = time0Rotations.map((r) => r.boneName)
|
|
1467
|
-
const rotations = time0Rotations.map((r) => r.rotation)
|
|
1468
|
-
this.rotateBones(boneNames, rotations, 0)
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
const skeleton = this.currentModel.getSkeleton()
|
|
1472
|
-
const bonesToReset: string[] = []
|
|
1473
|
-
for (const bone of skeleton.bones) {
|
|
1474
|
-
if (!bonesWithTime0.has(bone.name)) {
|
|
1475
|
-
bonesToReset.push(bone.name)
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
if (bonesToReset.length > 0) {
|
|
1480
|
-
const identityQuat = new Quat(0, 0, 0, 1)
|
|
1481
|
-
const identityQuats = new Array(bonesToReset.length).fill(identityQuat)
|
|
1482
|
-
this.rotateBones(bonesToReset, identityQuats, 0)
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
// Reset physics immediately and upload matrices to prevent A-pose flash
|
|
1486
|
-
if (this.physics) {
|
|
1487
|
-
this.currentModel.evaluatePose()
|
|
1488
|
-
|
|
1489
|
-
const worldMats = this.currentModel.getBoneWorldMatrices()
|
|
1490
|
-
this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices())
|
|
1491
|
-
|
|
1492
|
-
// Upload matrices immediately so next frame shows correct pose
|
|
1493
|
-
this.device.queue.writeBuffer(
|
|
1494
|
-
this.worldMatrixBuffer!,
|
|
1495
|
-
0,
|
|
1496
|
-
worldMats.buffer,
|
|
1497
|
-
worldMats.byteOffset,
|
|
1498
|
-
worldMats.byteLength
|
|
1499
|
-
)
|
|
1500
|
-
const encoder = this.device.createCommandEncoder()
|
|
1501
|
-
this.computeSkinMatrices(encoder)
|
|
1502
|
-
this.device.queue.submit([encoder.finish()])
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
|
|
1506
|
-
for (let i = 0; i < keyFrames.length; i++) {
|
|
1507
|
-
const boneKeyFrame = keyFrames[i]
|
|
1508
|
-
const previousBoneKeyFrame = i > 0 ? keyFrames[i - 1] : null
|
|
1509
|
-
|
|
1510
|
-
if (boneKeyFrame.time === 0) continue
|
|
1511
|
-
|
|
1512
|
-
let durationMs = 0
|
|
1513
|
-
if (i === 0) {
|
|
1514
|
-
durationMs = boneKeyFrame.time * 1000
|
|
1515
|
-
} else if (previousBoneKeyFrame) {
|
|
1516
|
-
durationMs = (boneKeyFrame.time - previousBoneKeyFrame.time) * 1000
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
const scheduleTime = i > 0 && previousBoneKeyFrame ? previousBoneKeyFrame.time : 0
|
|
1520
|
-
const delayMs = scheduleTime * 1000
|
|
1521
|
-
|
|
1522
|
-
if (delayMs <= 0) {
|
|
1523
|
-
this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs)
|
|
1524
|
-
} else {
|
|
1525
|
-
const timeoutId = window.setTimeout(() => {
|
|
1526
|
-
this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs)
|
|
1527
|
-
}, delayMs)
|
|
1528
|
-
this.animationTimeouts.push(timeoutId)
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
public stopAnimation() {
|
|
1535
|
-
for (const timeoutId of this.animationTimeouts) {
|
|
1536
|
-
clearTimeout(timeoutId)
|
|
1537
|
-
}
|
|
1538
|
-
this.animationTimeouts = []
|
|
1539
|
-
this.playingAnimation = false
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
public getStats(): EngineStats {
|
|
1543
|
-
return { ...this.stats }
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
public runRenderLoop(callback?: () => void) {
|
|
1547
|
-
this.renderLoopCallback = callback || null
|
|
1548
|
-
|
|
1549
|
-
const loop = () => {
|
|
1550
|
-
this.render()
|
|
1551
|
-
|
|
1552
|
-
if (this.renderLoopCallback) {
|
|
1553
|
-
this.renderLoopCallback()
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
this.animationFrameId = requestAnimationFrame(loop)
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
this.animationFrameId = requestAnimationFrame(loop)
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
public stopRenderLoop() {
|
|
1563
|
-
if (this.animationFrameId !== null) {
|
|
1564
|
-
cancelAnimationFrame(this.animationFrameId)
|
|
1565
|
-
this.animationFrameId = null
|
|
1566
|
-
}
|
|
1567
|
-
this.renderLoopCallback = null
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
public dispose() {
|
|
1571
|
-
this.stopRenderLoop()
|
|
1572
|
-
this.stopAnimation()
|
|
1573
|
-
if (this.camera) this.camera.detachControl()
|
|
1574
|
-
if (this.resizeObserver) {
|
|
1575
|
-
this.resizeObserver.disconnect()
|
|
1576
|
-
this.resizeObserver = null
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
// Step 6: Load PMX model file
|
|
1581
|
-
public async loadModel(path: string) {
|
|
1582
|
-
const pathParts = path.split("/")
|
|
1583
|
-
pathParts.pop()
|
|
1584
|
-
const dir = pathParts.join("/") + "/"
|
|
1585
|
-
this.modelDir = dir
|
|
1586
|
-
|
|
1587
|
-
const model = await PmxLoader.load(path)
|
|
1588
|
-
// console.log({
|
|
1589
|
-
// vertices: Array.from(model.getVertices()),
|
|
1590
|
-
// indices: Array.from(model.getIndices()),
|
|
1591
|
-
// materials: model.getMaterials(),
|
|
1592
|
-
// textures: model.getTextures(),
|
|
1593
|
-
// bones: model.getSkeleton().bones,
|
|
1594
|
-
// skinning: { joints: Array.from(model.getSkinning().joints), weights: Array.from(model.getSkinning().weights) },
|
|
1595
|
-
// })
|
|
1596
|
-
this.physics = new Physics(model.getRigidbodies(), model.getJoints())
|
|
1597
|
-
await this.setupModelBuffers(model)
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
public rotateBones(bones: string[], rotations: Quat[], durationMs?: number) {
|
|
1601
|
-
this.currentModel?.rotateBones(bones, rotations, durationMs)
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
// Step 7: Create vertex, index, and joint buffers
|
|
1605
|
-
private async setupModelBuffers(model: Model) {
|
|
1606
|
-
this.currentModel = model
|
|
1607
|
-
const vertices = model.getVertices()
|
|
1608
|
-
const skinning = model.getSkinning()
|
|
1609
|
-
const skeleton = model.getSkeleton()
|
|
1610
|
-
|
|
1611
|
-
this.vertexBuffer = this.device.createBuffer({
|
|
1612
|
-
label: "model vertex buffer",
|
|
1613
|
-
size: vertices.byteLength,
|
|
1614
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
1615
|
-
})
|
|
1616
|
-
this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
|
|
1617
|
-
|
|
1618
|
-
this.jointsBuffer = this.device.createBuffer({
|
|
1619
|
-
label: "joints buffer",
|
|
1620
|
-
size: skinning.joints.byteLength,
|
|
1621
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
1622
|
-
})
|
|
1623
|
-
this.device.queue.writeBuffer(
|
|
1624
|
-
this.jointsBuffer,
|
|
1625
|
-
0,
|
|
1626
|
-
skinning.joints.buffer,
|
|
1627
|
-
skinning.joints.byteOffset,
|
|
1628
|
-
skinning.joints.byteLength
|
|
1629
|
-
)
|
|
1630
|
-
|
|
1631
|
-
this.weightsBuffer = this.device.createBuffer({
|
|
1632
|
-
label: "weights buffer",
|
|
1633
|
-
size: skinning.weights.byteLength,
|
|
1634
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
1635
|
-
})
|
|
1636
|
-
this.device.queue.writeBuffer(
|
|
1637
|
-
this.weightsBuffer,
|
|
1638
|
-
0,
|
|
1639
|
-
skinning.weights.buffer,
|
|
1640
|
-
skinning.weights.byteOffset,
|
|
1641
|
-
skinning.weights.byteLength
|
|
1642
|
-
)
|
|
1643
|
-
|
|
1644
|
-
const boneCount = skeleton.bones.length
|
|
1645
|
-
const matrixSize = boneCount * 16 * 4
|
|
1646
|
-
|
|
1647
|
-
this.skinMatrixBuffer = this.device.createBuffer({
|
|
1648
|
-
label: "skin matrices",
|
|
1649
|
-
size: Math.max(256, matrixSize),
|
|
1650
|
-
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
|
|
1651
|
-
})
|
|
1652
|
-
|
|
1653
|
-
this.worldMatrixBuffer = this.device.createBuffer({
|
|
1654
|
-
label: "world matrices",
|
|
1655
|
-
size: Math.max(256, matrixSize),
|
|
1656
|
-
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
1657
|
-
})
|
|
1658
|
-
|
|
1659
|
-
this.inverseBindMatrixBuffer = this.device.createBuffer({
|
|
1660
|
-
label: "inverse bind matrices",
|
|
1661
|
-
size: Math.max(256, matrixSize),
|
|
1662
|
-
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
1663
|
-
})
|
|
1664
|
-
|
|
1665
|
-
const invBindMatrices = skeleton.inverseBindMatrices
|
|
1666
|
-
this.device.queue.writeBuffer(
|
|
1667
|
-
this.inverseBindMatrixBuffer,
|
|
1668
|
-
0,
|
|
1669
|
-
invBindMatrices.buffer,
|
|
1670
|
-
invBindMatrices.byteOffset,
|
|
1671
|
-
invBindMatrices.byteLength
|
|
1672
|
-
)
|
|
1673
|
-
|
|
1674
|
-
this.boneCountBuffer = this.device.createBuffer({
|
|
1675
|
-
label: "bone count uniform",
|
|
1676
|
-
size: 32, // Minimum uniform buffer size is 32 bytes
|
|
1677
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1678
|
-
})
|
|
1679
|
-
const boneCountData = new Uint32Array(8) // 32 bytes total
|
|
1680
|
-
boneCountData[0] = boneCount
|
|
1681
|
-
this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData)
|
|
1682
|
-
|
|
1683
|
-
this.createSkinMatrixComputePipeline()
|
|
1684
|
-
|
|
1685
|
-
// Create compute bind group once (reused every frame)
|
|
1686
|
-
this.skinMatrixComputeBindGroup = this.device.createBindGroup({
|
|
1687
|
-
layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
|
|
1688
|
-
entries: [
|
|
1689
|
-
{ binding: 0, resource: { buffer: this.boneCountBuffer } },
|
|
1690
|
-
{ binding: 1, resource: { buffer: this.worldMatrixBuffer } },
|
|
1691
|
-
{ binding: 2, resource: { buffer: this.inverseBindMatrixBuffer } },
|
|
1692
|
-
{ binding: 3, resource: { buffer: this.skinMatrixBuffer } },
|
|
1693
|
-
],
|
|
1694
|
-
})
|
|
1695
|
-
|
|
1696
|
-
const indices = model.getIndices()
|
|
1697
|
-
if (indices) {
|
|
1698
|
-
this.indexBuffer = this.device.createBuffer({
|
|
1699
|
-
label: "model index buffer",
|
|
1700
|
-
size: indices.byteLength,
|
|
1701
|
-
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
1702
|
-
})
|
|
1703
|
-
this.device.queue.writeBuffer(this.indexBuffer, 0, indices)
|
|
1704
|
-
} else {
|
|
1705
|
-
throw new Error("Model has no index buffer")
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
await this.setupMaterials(model)
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
private async setupMaterials(model: Model) {
|
|
1712
|
-
const materials = model.getMaterials()
|
|
1713
|
-
if (materials.length === 0) {
|
|
1714
|
-
throw new Error("Model has no materials")
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
const textures = model.getTextures()
|
|
1718
|
-
|
|
1719
|
-
const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
|
|
1720
|
-
if (texIndex < 0 || texIndex >= textures.length) {
|
|
1721
|
-
return null
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
const path = this.modelDir + textures[texIndex].path
|
|
1725
|
-
const texture = await this.createTextureFromPath(path)
|
|
1726
|
-
return texture
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
const loadToonTexture = async (toonTextureIndex: number): Promise<GPUTexture> => {
|
|
1730
|
-
const texture = await loadTextureByIndex(toonTextureIndex)
|
|
1731
|
-
if (texture) return texture
|
|
1732
|
-
|
|
1733
|
-
// Default toon texture fallback - cache it
|
|
1734
|
-
const defaultToonPath = "__default_toon__"
|
|
1735
|
-
const cached = this.textureCache.get(defaultToonPath)
|
|
1736
|
-
if (cached) return cached
|
|
1737
|
-
|
|
1738
|
-
const defaultToonData = new Uint8Array(256 * 2 * 4)
|
|
1739
|
-
for (let i = 0; i < 256; i++) {
|
|
1740
|
-
const factor = i / 255.0
|
|
1741
|
-
const gray = Math.floor(128 + factor * 127)
|
|
1742
|
-
defaultToonData[i * 4] = gray
|
|
1743
|
-
defaultToonData[i * 4 + 1] = gray
|
|
1744
|
-
defaultToonData[i * 4 + 2] = gray
|
|
1745
|
-
defaultToonData[i * 4 + 3] = 255
|
|
1746
|
-
defaultToonData[(256 + i) * 4] = gray
|
|
1747
|
-
defaultToonData[(256 + i) * 4 + 1] = gray
|
|
1748
|
-
defaultToonData[(256 + i) * 4 + 2] = gray
|
|
1749
|
-
defaultToonData[(256 + i) * 4 + 3] = 255
|
|
1750
|
-
}
|
|
1751
|
-
const defaultToonTexture = this.device.createTexture({
|
|
1752
|
-
label: "default toon texture",
|
|
1753
|
-
size: [256, 2],
|
|
1754
|
-
format: "rgba8unorm",
|
|
1755
|
-
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
1756
|
-
})
|
|
1757
|
-
this.device.queue.writeTexture(
|
|
1758
|
-
{ texture: defaultToonTexture },
|
|
1759
|
-
defaultToonData,
|
|
1760
|
-
{ bytesPerRow: 256 * 4 },
|
|
1761
|
-
[256, 2]
|
|
1762
|
-
)
|
|
1763
|
-
this.textureCache.set(defaultToonPath, defaultToonTexture)
|
|
1764
|
-
return defaultToonTexture
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
this.opaqueDraws = []
|
|
1768
|
-
this.eyeDraws = []
|
|
1769
|
-
this.hairDrawsOverEyes = []
|
|
1770
|
-
this.hairDrawsOverNonEyes = []
|
|
1771
|
-
this.transparentDraws = []
|
|
1772
|
-
this.opaqueOutlineDraws = []
|
|
1773
|
-
this.eyeOutlineDraws = []
|
|
1774
|
-
this.hairOutlineDraws = []
|
|
1775
|
-
this.transparentOutlineDraws = []
|
|
1776
|
-
let currentIndexOffset = 0
|
|
1777
|
-
|
|
1778
|
-
for (const mat of materials) {
|
|
1779
|
-
const indexCount = mat.vertexCount
|
|
1780
|
-
if (indexCount === 0) continue
|
|
1781
|
-
|
|
1782
|
-
const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
|
|
1783
|
-
if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
|
|
1784
|
-
|
|
1785
|
-
const toonTexture = await loadToonTexture(mat.toonTextureIndex)
|
|
1786
|
-
|
|
1787
|
-
const materialAlpha = mat.diffuse[3]
|
|
1788
|
-
const EPSILON = 0.001
|
|
1789
|
-
const isTransparent = materialAlpha < 1.0 - EPSILON
|
|
1790
|
-
|
|
1791
|
-
// Create material uniform data
|
|
1792
|
-
const materialUniformData = new Float32Array(8)
|
|
1793
|
-
materialUniformData[0] = materialAlpha
|
|
1794
|
-
materialUniformData[1] = 1.0 // alphaMultiplier: 1.0 for non-hair materials
|
|
1795
|
-
materialUniformData[2] = this.rimLightIntensity
|
|
1796
|
-
materialUniformData[3] = 0.0 // _padding1
|
|
1797
|
-
materialUniformData[4] = 1.0 // rimColor.r
|
|
1798
|
-
materialUniformData[5] = 1.0 // rimColor.g
|
|
1799
|
-
materialUniformData[6] = 1.0 // rimColor.b
|
|
1800
|
-
materialUniformData[7] = 0.0 // isOverEyes
|
|
1801
|
-
|
|
1802
|
-
const materialUniformBuffer = this.device.createBuffer({
|
|
1803
|
-
label: `material uniform: ${mat.name}`,
|
|
1804
|
-
size: materialUniformData.byteLength,
|
|
1805
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1806
|
-
})
|
|
1807
|
-
this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
|
|
1808
|
-
|
|
1809
|
-
// Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
|
|
1810
|
-
const bindGroup = this.device.createBindGroup({
|
|
1811
|
-
label: `material bind group: ${mat.name}`,
|
|
1812
|
-
layout: this.mainBindGroupLayout,
|
|
1813
|
-
entries: [
|
|
1814
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
1815
|
-
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
1816
|
-
{ binding: 2, resource: diffuseTexture.createView() },
|
|
1817
|
-
{ binding: 3, resource: this.materialSampler },
|
|
1818
|
-
{ binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
|
|
1819
|
-
{ binding: 5, resource: toonTexture.createView() },
|
|
1820
|
-
{ binding: 6, resource: this.materialSampler },
|
|
1821
|
-
{ binding: 7, resource: { buffer: materialUniformBuffer } },
|
|
1822
|
-
],
|
|
1823
|
-
})
|
|
1824
|
-
|
|
1825
|
-
if (mat.isEye) {
|
|
1826
|
-
this.eyeDraws.push({
|
|
1827
|
-
count: indexCount,
|
|
1828
|
-
firstIndex: currentIndexOffset,
|
|
1829
|
-
bindGroup,
|
|
1830
|
-
isTransparent,
|
|
1831
|
-
})
|
|
1832
|
-
} else if (mat.isHair) {
|
|
1833
|
-
// Hair materials: create separate bind groups for over-eyes vs over-non-eyes
|
|
1834
|
-
const createHairBindGroup = (isOverEyes: boolean) => {
|
|
1835
|
-
const uniformData = new Float32Array(8)
|
|
1836
|
-
uniformData[0] = materialAlpha
|
|
1837
|
-
uniformData[1] = 1.0 // alphaMultiplier (shader adjusts based on isOverEyes)
|
|
1838
|
-
uniformData[2] = this.rimLightIntensity
|
|
1839
|
-
uniformData[3] = 0.0 // _padding1
|
|
1840
|
-
uniformData[4] = 1.0 // rimColor.rgb
|
|
1841
|
-
uniformData[5] = 1.0
|
|
1842
|
-
uniformData[6] = 1.0
|
|
1843
|
-
uniformData[7] = isOverEyes ? 1.0 : 0.0 // isOverEyes
|
|
1844
|
-
|
|
1845
|
-
const buffer = this.device.createBuffer({
|
|
1846
|
-
label: `material uniform (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
|
|
1847
|
-
size: uniformData.byteLength,
|
|
1848
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1849
|
-
})
|
|
1850
|
-
this.device.queue.writeBuffer(buffer, 0, uniformData)
|
|
1851
|
-
|
|
1852
|
-
return this.device.createBindGroup({
|
|
1853
|
-
label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
|
|
1854
|
-
layout: this.mainBindGroupLayout,
|
|
1855
|
-
entries: [
|
|
1856
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
1857
|
-
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
1858
|
-
{ binding: 2, resource: diffuseTexture.createView() },
|
|
1859
|
-
{ binding: 3, resource: this.materialSampler },
|
|
1860
|
-
{ binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
|
|
1861
|
-
{ binding: 5, resource: toonTexture.createView() },
|
|
1862
|
-
{ binding: 6, resource: this.materialSampler },
|
|
1863
|
-
{ binding: 7, resource: { buffer: buffer } },
|
|
1864
|
-
],
|
|
1865
|
-
})
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
const bindGroupOverEyes = createHairBindGroup(true)
|
|
1869
|
-
const bindGroupOverNonEyes = createHairBindGroup(false)
|
|
1870
|
-
|
|
1871
|
-
this.hairDrawsOverEyes.push({
|
|
1872
|
-
count: indexCount,
|
|
1873
|
-
firstIndex: currentIndexOffset,
|
|
1874
|
-
bindGroup: bindGroupOverEyes,
|
|
1875
|
-
isTransparent,
|
|
1876
|
-
})
|
|
1877
|
-
|
|
1878
|
-
this.hairDrawsOverNonEyes.push({
|
|
1879
|
-
count: indexCount,
|
|
1880
|
-
firstIndex: currentIndexOffset,
|
|
1881
|
-
bindGroup: bindGroupOverNonEyes,
|
|
1882
|
-
isTransparent,
|
|
1883
|
-
})
|
|
1884
|
-
} else if (isTransparent) {
|
|
1885
|
-
this.transparentDraws.push({
|
|
1886
|
-
count: indexCount,
|
|
1887
|
-
firstIndex: currentIndexOffset,
|
|
1888
|
-
bindGroup,
|
|
1889
|
-
isTransparent,
|
|
1890
|
-
})
|
|
1891
|
-
} else {
|
|
1892
|
-
this.opaqueDraws.push({
|
|
1893
|
-
count: indexCount,
|
|
1894
|
-
firstIndex: currentIndexOffset,
|
|
1895
|
-
bindGroup,
|
|
1896
|
-
isTransparent,
|
|
1897
|
-
})
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
// Edge flag is at bit 4 (0x10) in PMX format
|
|
1901
|
-
if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
|
|
1902
|
-
const materialUniformData = new Float32Array(8)
|
|
1903
|
-
materialUniformData[0] = mat.edgeColor[0] // edgeColor.r
|
|
1904
|
-
materialUniformData[1] = mat.edgeColor[1] // edgeColor.g
|
|
1905
|
-
materialUniformData[2] = mat.edgeColor[2] // edgeColor.b
|
|
1906
|
-
materialUniformData[3] = mat.edgeColor[3] // edgeColor.a
|
|
1907
|
-
materialUniformData[4] = mat.edgeSize
|
|
1908
|
-
materialUniformData[5] = 0.0 // isOverEyes
|
|
1909
|
-
materialUniformData[6] = 0.0
|
|
1910
|
-
materialUniformData[7] = 0.0
|
|
1911
|
-
|
|
1912
|
-
const materialUniformBuffer = this.device.createBuffer({
|
|
1913
|
-
label: `outline material uniform: ${mat.name}`,
|
|
1914
|
-
size: materialUniformData.byteLength,
|
|
1915
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1916
|
-
})
|
|
1917
|
-
this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
|
|
1918
|
-
|
|
1919
|
-
const outlineBindGroup = this.device.createBindGroup({
|
|
1920
|
-
label: `outline bind group: ${mat.name}`,
|
|
1921
|
-
layout: this.outlineBindGroupLayout,
|
|
1922
|
-
entries: [
|
|
1923
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
1924
|
-
{ binding: 1, resource: { buffer: materialUniformBuffer } },
|
|
1925
|
-
{ binding: 2, resource: { buffer: this.skinMatrixBuffer! } },
|
|
1926
|
-
],
|
|
1927
|
-
})
|
|
1928
|
-
|
|
1929
|
-
if (mat.isEye) {
|
|
1930
|
-
this.eyeOutlineDraws.push({
|
|
1931
|
-
count: indexCount,
|
|
1932
|
-
firstIndex: currentIndexOffset,
|
|
1933
|
-
bindGroup: outlineBindGroup,
|
|
1934
|
-
isTransparent,
|
|
1935
|
-
})
|
|
1936
|
-
} else if (mat.isHair) {
|
|
1937
|
-
this.hairOutlineDraws.push({
|
|
1938
|
-
count: indexCount,
|
|
1939
|
-
firstIndex: currentIndexOffset,
|
|
1940
|
-
bindGroup: outlineBindGroup,
|
|
1941
|
-
isTransparent,
|
|
1942
|
-
})
|
|
1943
|
-
} else if (isTransparent) {
|
|
1944
|
-
this.transparentOutlineDraws.push({
|
|
1945
|
-
count: indexCount,
|
|
1946
|
-
firstIndex: currentIndexOffset,
|
|
1947
|
-
bindGroup: outlineBindGroup,
|
|
1948
|
-
isTransparent,
|
|
1949
|
-
})
|
|
1950
|
-
} else {
|
|
1951
|
-
this.opaqueOutlineDraws.push({
|
|
1952
|
-
count: indexCount,
|
|
1953
|
-
firstIndex: currentIndexOffset,
|
|
1954
|
-
bindGroup: outlineBindGroup,
|
|
1955
|
-
isTransparent,
|
|
1956
|
-
})
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
currentIndexOffset += indexCount
|
|
1961
|
-
}
|
|
1962
|
-
|
|
1963
|
-
this.gpuMemoryMB = this.calculateGpuMemory()
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
|
|
1967
|
-
const cached = this.textureCache.get(path)
|
|
1968
|
-
if (cached) {
|
|
1969
|
-
return cached
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
try {
|
|
1973
|
-
const response = await fetch(path)
|
|
1974
|
-
if (!response.ok) {
|
|
1975
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
1976
|
-
}
|
|
1977
|
-
const imageBitmap = await createImageBitmap(await response.blob(), {
|
|
1978
|
-
premultiplyAlpha: "none",
|
|
1979
|
-
colorSpaceConversion: "none",
|
|
1980
|
-
})
|
|
1981
|
-
|
|
1982
|
-
const texture = this.device.createTexture({
|
|
1983
|
-
label: `texture: ${path}`,
|
|
1984
|
-
size: [imageBitmap.width, imageBitmap.height],
|
|
1985
|
-
format: "rgba8unorm",
|
|
1986
|
-
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1987
|
-
})
|
|
1988
|
-
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
|
|
1989
|
-
imageBitmap.width,
|
|
1990
|
-
imageBitmap.height,
|
|
1991
|
-
])
|
|
1992
|
-
|
|
1993
|
-
this.textureCache.set(path, texture)
|
|
1994
|
-
return texture
|
|
1995
|
-
} catch {
|
|
1996
|
-
return null
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
// Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
|
|
2001
|
-
public render() {
|
|
2002
|
-
if (this.multisampleTexture && this.camera && this.device
|
|
2003
|
-
const currentTime = performance.now()
|
|
2004
|
-
const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
|
|
2005
|
-
this.lastFrameTime = currentTime
|
|
2006
|
-
|
|
2007
|
-
this.updateCameraUniforms()
|
|
2008
|
-
this.updateRenderTarget()
|
|
2009
|
-
|
|
2010
|
-
// Use single encoder for both compute and render (reduces sync points)
|
|
2011
|
-
const encoder = this.device.createCommandEncoder()
|
|
2012
|
-
|
|
2013
|
-
this.updateModelPose(deltaTime, encoder)
|
|
2014
|
-
|
|
2015
|
-
// Hide model if animation is loaded but not playing yet (prevents A-pose flash)
|
|
2016
|
-
// Still update physics and poses, just don't render visually
|
|
2017
|
-
if (this.hasAnimation && !this.playingAnimation) {
|
|
2018
|
-
// Submit encoder to ensure matrices are uploaded and physics initializes
|
|
2019
|
-
this.device.queue.submit([encoder.finish()])
|
|
2020
|
-
return
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
const pass = encoder.beginRenderPass(this.renderPassDescriptor)
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
this.
|
|
2125
|
-
|
|
2126
|
-
this.
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
extractPass.
|
|
2162
|
-
extractPass.
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
hBlurData
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
blurHPass.
|
|
2184
|
-
blurHPass.
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
vBlurData
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
blurVPass.
|
|
2206
|
-
blurVPass.
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
composePass.
|
|
2224
|
-
composePass.
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
const
|
|
2233
|
-
this.
|
|
2234
|
-
this.
|
|
2235
|
-
this.cameraMatrixData
|
|
2236
|
-
this.cameraMatrixData
|
|
2237
|
-
this.cameraMatrixData[
|
|
2238
|
-
this.
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
colorAttachment.
|
|
2247
|
-
}
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
worldMats.
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
const
|
|
2273
|
-
|
|
2274
|
-
pass.
|
|
2275
|
-
pass.
|
|
2276
|
-
pass.
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
const
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
this.
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
bufferMemoryBytes +=
|
|
2360
|
-
bufferMemoryBytes +=
|
|
2361
|
-
bufferMemoryBytes += 32
|
|
2362
|
-
bufferMemoryBytes += 32
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
this.
|
|
2370
|
-
this.
|
|
2371
|
-
this.
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
this.
|
|
2378
|
-
this.
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
1
|
+
import { Camera } from "./camera"
|
|
2
|
+
import { Quat, Vec3 } from "./math"
|
|
3
|
+
import { Model } from "./model"
|
|
4
|
+
import { PmxLoader } from "./pmx-loader"
|
|
5
|
+
import { Physics } from "./physics"
|
|
6
|
+
import { VMDKeyFrame, VMDLoader } from "./vmd-loader"
|
|
7
|
+
|
|
8
|
+
export type EngineOptions = {
|
|
9
|
+
ambient?: number
|
|
10
|
+
bloomIntensity?: number
|
|
11
|
+
rimLightIntensity?: number
|
|
12
|
+
cameraDistance?: number
|
|
13
|
+
cameraTarget?: Vec3
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EngineStats {
|
|
17
|
+
fps: number
|
|
18
|
+
frameTime: number // ms
|
|
19
|
+
gpuMemory: number // MB (estimated total GPU memory)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface DrawCall {
|
|
23
|
+
count: number
|
|
24
|
+
firstIndex: number
|
|
25
|
+
bindGroup: GPUBindGroup
|
|
26
|
+
isTransparent: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type BoneKeyFrame = {
|
|
30
|
+
boneName: string
|
|
31
|
+
time: number
|
|
32
|
+
rotation: Quat
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Engine {
|
|
36
|
+
private canvas: HTMLCanvasElement
|
|
37
|
+
private device!: GPUDevice
|
|
38
|
+
private context!: GPUCanvasContext
|
|
39
|
+
private presentationFormat!: GPUTextureFormat
|
|
40
|
+
private camera!: Camera
|
|
41
|
+
private cameraUniformBuffer!: GPUBuffer
|
|
42
|
+
private cameraMatrixData = new Float32Array(36)
|
|
43
|
+
private cameraDistance: number = 26.6
|
|
44
|
+
private cameraTarget: Vec3 = new Vec3(0, 12.5, 0)
|
|
45
|
+
private lightUniformBuffer!: GPUBuffer
|
|
46
|
+
private lightData = new Float32Array(64)
|
|
47
|
+
private lightCount = 0
|
|
48
|
+
private vertexBuffer!: GPUBuffer
|
|
49
|
+
private indexBuffer?: GPUBuffer
|
|
50
|
+
private resizeObserver: ResizeObserver | null = null
|
|
51
|
+
private depthTexture!: GPUTexture
|
|
52
|
+
// Material rendering pipelines
|
|
53
|
+
private modelPipeline!: GPURenderPipeline
|
|
54
|
+
private eyePipeline!: GPURenderPipeline
|
|
55
|
+
private hairPipelineOverEyes!: GPURenderPipeline
|
|
56
|
+
private hairPipelineOverNonEyes!: GPURenderPipeline
|
|
57
|
+
private hairDepthPipeline!: GPURenderPipeline
|
|
58
|
+
// Outline pipelines
|
|
59
|
+
private outlinePipeline!: GPURenderPipeline
|
|
60
|
+
private hairOutlinePipeline!: GPURenderPipeline
|
|
61
|
+
private mainBindGroupLayout!: GPUBindGroupLayout
|
|
62
|
+
private outlineBindGroupLayout!: GPUBindGroupLayout
|
|
63
|
+
private jointsBuffer!: GPUBuffer
|
|
64
|
+
private weightsBuffer!: GPUBuffer
|
|
65
|
+
private skinMatrixBuffer?: GPUBuffer
|
|
66
|
+
private worldMatrixBuffer?: GPUBuffer
|
|
67
|
+
private inverseBindMatrixBuffer?: GPUBuffer
|
|
68
|
+
private skinMatrixComputePipeline?: GPUComputePipeline
|
|
69
|
+
private skinMatrixComputeBindGroup?: GPUBindGroup
|
|
70
|
+
private boneCountBuffer?: GPUBuffer
|
|
71
|
+
private multisampleTexture!: GPUTexture
|
|
72
|
+
private readonly sampleCount = 4
|
|
73
|
+
private renderPassDescriptor!: GPURenderPassDescriptor
|
|
74
|
+
// Constants
|
|
75
|
+
private readonly STENCIL_EYE_VALUE = 1
|
|
76
|
+
private readonly COMPUTE_WORKGROUP_SIZE = 64
|
|
77
|
+
private readonly BLOOM_DOWNSCALE_FACTOR = 2
|
|
78
|
+
// Ambient light settings
|
|
79
|
+
private ambient: number = 1.0
|
|
80
|
+
// Bloom post-processing textures
|
|
81
|
+
private sceneRenderTexture!: GPUTexture
|
|
82
|
+
private sceneRenderTextureView!: GPUTextureView
|
|
83
|
+
private bloomExtractTexture!: GPUTexture
|
|
84
|
+
private bloomBlurTexture1!: GPUTexture
|
|
85
|
+
private bloomBlurTexture2!: GPUTexture
|
|
86
|
+
// Post-processing pipelines
|
|
87
|
+
private bloomExtractPipeline!: GPURenderPipeline
|
|
88
|
+
private bloomBlurPipeline!: GPURenderPipeline
|
|
89
|
+
private bloomComposePipeline!: GPURenderPipeline
|
|
90
|
+
// Fullscreen quad for post-processing
|
|
91
|
+
private fullscreenQuadBuffer!: GPUBuffer
|
|
92
|
+
private blurDirectionBuffer!: GPUBuffer
|
|
93
|
+
private bloomIntensityBuffer!: GPUBuffer
|
|
94
|
+
private bloomThresholdBuffer!: GPUBuffer
|
|
95
|
+
private linearSampler!: GPUSampler
|
|
96
|
+
// Bloom bind groups (created once, reused every frame)
|
|
97
|
+
private bloomExtractBindGroup?: GPUBindGroup
|
|
98
|
+
private bloomBlurHBindGroup?: GPUBindGroup
|
|
99
|
+
private bloomBlurVBindGroup?: GPUBindGroup
|
|
100
|
+
private bloomComposeBindGroup?: GPUBindGroup
|
|
101
|
+
// Bloom settings
|
|
102
|
+
private bloomThreshold: number = 0.3
|
|
103
|
+
private bloomIntensity: number = 0.12
|
|
104
|
+
// Rim light settings
|
|
105
|
+
private rimLightIntensity: number = 0.45
|
|
106
|
+
|
|
107
|
+
private currentModel: Model | null = null
|
|
108
|
+
private modelDir: string = ""
|
|
109
|
+
private physics: Physics | null = null
|
|
110
|
+
private materialSampler!: GPUSampler
|
|
111
|
+
private textureCache = new Map<string, GPUTexture>()
|
|
112
|
+
// Draw lists
|
|
113
|
+
private opaqueDraws: DrawCall[] = []
|
|
114
|
+
private eyeDraws: DrawCall[] = []
|
|
115
|
+
private hairDrawsOverEyes: DrawCall[] = []
|
|
116
|
+
private hairDrawsOverNonEyes: DrawCall[] = []
|
|
117
|
+
private transparentDraws: DrawCall[] = []
|
|
118
|
+
private opaqueOutlineDraws: DrawCall[] = []
|
|
119
|
+
private eyeOutlineDraws: DrawCall[] = []
|
|
120
|
+
private hairOutlineDraws: DrawCall[] = []
|
|
121
|
+
private transparentOutlineDraws: DrawCall[] = []
|
|
122
|
+
|
|
123
|
+
private lastFpsUpdate = performance.now()
|
|
124
|
+
private framesSinceLastUpdate = 0
|
|
125
|
+
private frameTimeSamples: number[] = []
|
|
126
|
+
private frameTimeSum: number = 0
|
|
127
|
+
private drawCallCount: number = 0
|
|
128
|
+
private lastFrameTime = performance.now()
|
|
129
|
+
private stats: EngineStats = {
|
|
130
|
+
fps: 0,
|
|
131
|
+
frameTime: 0,
|
|
132
|
+
gpuMemory: 0,
|
|
133
|
+
}
|
|
134
|
+
private animationFrameId: number | null = null
|
|
135
|
+
private renderLoopCallback: (() => void) | null = null
|
|
136
|
+
|
|
137
|
+
private animationFrames: VMDKeyFrame[] = []
|
|
138
|
+
private animationTimeouts: number[] = []
|
|
139
|
+
private gpuMemoryMB: number = 0
|
|
140
|
+
private hasAnimation = false // Set to true when loadAnimation is called
|
|
141
|
+
private playingAnimation = false // Set to true when playAnimation is called
|
|
142
|
+
|
|
143
|
+
constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
|
|
144
|
+
this.canvas = canvas
|
|
145
|
+
if (options) {
|
|
146
|
+
this.ambient = options.ambient ?? 1.0
|
|
147
|
+
this.bloomIntensity = options.bloomIntensity ?? 0.12
|
|
148
|
+
this.rimLightIntensity = options.rimLightIntensity ?? 0.45
|
|
149
|
+
this.cameraDistance = options.cameraDistance ?? 26.6
|
|
150
|
+
this.cameraTarget = options.cameraTarget ?? new Vec3(0, 12.5, 0)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Step 1: Get WebGPU device and context
|
|
155
|
+
public async init() {
|
|
156
|
+
const adapter = await navigator.gpu?.requestAdapter()
|
|
157
|
+
const device = await adapter?.requestDevice()
|
|
158
|
+
if (!device) {
|
|
159
|
+
throw new Error("WebGPU is not supported in this browser.")
|
|
160
|
+
}
|
|
161
|
+
this.device = device
|
|
162
|
+
|
|
163
|
+
const context = this.canvas.getContext("webgpu")
|
|
164
|
+
if (!context) {
|
|
165
|
+
throw new Error("Failed to get WebGPU context.")
|
|
166
|
+
}
|
|
167
|
+
this.context = context
|
|
168
|
+
|
|
169
|
+
this.presentationFormat = navigator.gpu.getPreferredCanvasFormat()
|
|
170
|
+
|
|
171
|
+
this.context.configure({
|
|
172
|
+
device: this.device,
|
|
173
|
+
format: this.presentationFormat,
|
|
174
|
+
alphaMode: "premultiplied",
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
this.setupCamera()
|
|
178
|
+
this.setupLighting()
|
|
179
|
+
this.createPipelines()
|
|
180
|
+
this.createFullscreenQuad()
|
|
181
|
+
this.createBloomPipelines()
|
|
182
|
+
this.setupResize()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private createPipelines() {
|
|
186
|
+
this.materialSampler = this.device.createSampler({
|
|
187
|
+
magFilter: "linear",
|
|
188
|
+
minFilter: "linear",
|
|
189
|
+
addressModeU: "repeat",
|
|
190
|
+
addressModeV: "repeat",
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const shaderModule = this.device.createShaderModule({
|
|
194
|
+
label: "model shaders",
|
|
195
|
+
code: /* wgsl */ `
|
|
196
|
+
struct CameraUniforms {
|
|
197
|
+
view: mat4x4f,
|
|
198
|
+
projection: mat4x4f,
|
|
199
|
+
viewPos: vec3f,
|
|
200
|
+
_padding: f32,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
struct Light {
|
|
204
|
+
direction: vec3f,
|
|
205
|
+
_padding1: f32,
|
|
206
|
+
color: vec3f,
|
|
207
|
+
intensity: f32,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
struct LightUniforms {
|
|
211
|
+
ambient: f32,
|
|
212
|
+
lightCount: f32,
|
|
213
|
+
_padding1: f32,
|
|
214
|
+
_padding2: f32,
|
|
215
|
+
lights: array<Light, 4>,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
struct MaterialUniforms {
|
|
219
|
+
alpha: f32,
|
|
220
|
+
alphaMultiplier: f32,
|
|
221
|
+
rimIntensity: f32,
|
|
222
|
+
_padding1: f32,
|
|
223
|
+
rimColor: vec3f,
|
|
224
|
+
isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
struct VertexOutput {
|
|
228
|
+
@builtin(position) position: vec4f,
|
|
229
|
+
@location(0) normal: vec3f,
|
|
230
|
+
@location(1) uv: vec2f,
|
|
231
|
+
@location(2) worldPos: vec3f,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
235
|
+
@group(0) @binding(1) var<uniform> light: LightUniforms;
|
|
236
|
+
@group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
|
|
237
|
+
@group(0) @binding(3) var diffuseSampler: sampler;
|
|
238
|
+
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
239
|
+
@group(0) @binding(5) var toonTexture: texture_2d<f32>;
|
|
240
|
+
@group(0) @binding(6) var toonSampler: sampler;
|
|
241
|
+
@group(0) @binding(7) var<uniform> material: MaterialUniforms;
|
|
242
|
+
|
|
243
|
+
@vertex fn vs(
|
|
244
|
+
@location(0) position: vec3f,
|
|
245
|
+
@location(1) normal: vec3f,
|
|
246
|
+
@location(2) uv: vec2f,
|
|
247
|
+
@location(3) joints0: vec4<u32>,
|
|
248
|
+
@location(4) weights0: vec4<f32>
|
|
249
|
+
) -> VertexOutput {
|
|
250
|
+
var output: VertexOutput;
|
|
251
|
+
let pos4 = vec4f(position, 1.0);
|
|
252
|
+
|
|
253
|
+
// Branchless weight normalization (avoids GPU branch divergence)
|
|
254
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
255
|
+
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
256
|
+
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
257
|
+
|
|
258
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
259
|
+
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
260
|
+
for (var i = 0u; i < 4u; i++) {
|
|
261
|
+
let j = joints0[i];
|
|
262
|
+
let w = normalizedWeights[i];
|
|
263
|
+
let m = skinMats[j];
|
|
264
|
+
skinnedPos += (m * pos4) * w;
|
|
265
|
+
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
266
|
+
skinnedNrm += (r3 * normal) * w;
|
|
267
|
+
}
|
|
268
|
+
let worldPos = skinnedPos.xyz;
|
|
269
|
+
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
270
|
+
output.normal = normalize(skinnedNrm);
|
|
271
|
+
output.uv = uv;
|
|
272
|
+
output.worldPos = worldPos;
|
|
273
|
+
return output;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
277
|
+
// Early alpha test - discard before expensive calculations
|
|
278
|
+
var finalAlpha = material.alpha * material.alphaMultiplier;
|
|
279
|
+
if (material.isOverEyes > 0.5) {
|
|
280
|
+
finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
|
|
281
|
+
}
|
|
282
|
+
if (finalAlpha < 0.001) {
|
|
283
|
+
discard;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let n = normalize(input.normal);
|
|
287
|
+
let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
288
|
+
|
|
289
|
+
var lightAccum = vec3f(light.ambient);
|
|
290
|
+
let numLights = u32(light.lightCount);
|
|
291
|
+
for (var i = 0u; i < numLights; i++) {
|
|
292
|
+
let l = -light.lights[i].direction;
|
|
293
|
+
let nDotL = max(dot(n, l), 0.0);
|
|
294
|
+
let toonUV = vec2f(nDotL, 0.5);
|
|
295
|
+
let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
|
|
296
|
+
let radiance = light.lights[i].color * light.lights[i].intensity;
|
|
297
|
+
lightAccum += toonFactor * radiance * nDotL;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Rim light calculation
|
|
301
|
+
let viewDir = normalize(camera.viewPos - input.worldPos);
|
|
302
|
+
var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
|
|
303
|
+
rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
|
|
304
|
+
let rimLight = material.rimColor * material.rimIntensity * rimFactor;
|
|
305
|
+
|
|
306
|
+
let color = albedo * lightAccum + rimLight;
|
|
307
|
+
|
|
308
|
+
return vec4f(color, finalAlpha);
|
|
309
|
+
}
|
|
310
|
+
`,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// Create explicit bind group layout for all pipelines using the main shader
|
|
314
|
+
this.mainBindGroupLayout = this.device.createBindGroupLayout({
|
|
315
|
+
label: "main material bind group layout",
|
|
316
|
+
entries: [
|
|
317
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
|
|
318
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
|
|
319
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
|
|
320
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
|
|
321
|
+
{ binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
|
|
322
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // toonTexture
|
|
323
|
+
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // toonSampler
|
|
324
|
+
{ binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
|
|
325
|
+
],
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const mainPipelineLayout = this.device.createPipelineLayout({
|
|
329
|
+
label: "main pipeline layout",
|
|
330
|
+
bindGroupLayouts: [this.mainBindGroupLayout],
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
this.modelPipeline = this.device.createRenderPipeline({
|
|
334
|
+
label: "model pipeline",
|
|
335
|
+
layout: mainPipelineLayout,
|
|
336
|
+
vertex: {
|
|
337
|
+
module: shaderModule,
|
|
338
|
+
buffers: [
|
|
339
|
+
{
|
|
340
|
+
arrayStride: 8 * 4,
|
|
341
|
+
attributes: [
|
|
342
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
343
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
344
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
arrayStride: 4 * 2,
|
|
349
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
arrayStride: 4,
|
|
353
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
fragment: {
|
|
358
|
+
module: shaderModule,
|
|
359
|
+
targets: [
|
|
360
|
+
{
|
|
361
|
+
format: this.presentationFormat,
|
|
362
|
+
blend: {
|
|
363
|
+
color: {
|
|
364
|
+
srcFactor: "src-alpha",
|
|
365
|
+
dstFactor: "one-minus-src-alpha",
|
|
366
|
+
operation: "add",
|
|
367
|
+
},
|
|
368
|
+
alpha: {
|
|
369
|
+
srcFactor: "one",
|
|
370
|
+
dstFactor: "one-minus-src-alpha",
|
|
371
|
+
operation: "add",
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
primitive: { cullMode: "none" },
|
|
378
|
+
depthStencil: {
|
|
379
|
+
format: "depth24plus-stencil8",
|
|
380
|
+
depthWriteEnabled: true,
|
|
381
|
+
depthCompare: "less-equal",
|
|
382
|
+
},
|
|
383
|
+
multisample: {
|
|
384
|
+
count: this.sampleCount,
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// Create bind group layout for outline pipelines
|
|
389
|
+
this.outlineBindGroupLayout = this.device.createBindGroupLayout({
|
|
390
|
+
label: "outline bind group layout",
|
|
391
|
+
entries: [
|
|
392
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
|
|
393
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
|
|
394
|
+
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
|
|
395
|
+
],
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const outlinePipelineLayout = this.device.createPipelineLayout({
|
|
399
|
+
label: "outline pipeline layout",
|
|
400
|
+
bindGroupLayouts: [this.outlineBindGroupLayout],
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const outlineShaderModule = this.device.createShaderModule({
|
|
404
|
+
label: "outline shaders",
|
|
405
|
+
code: /* wgsl */ `
|
|
406
|
+
struct CameraUniforms {
|
|
407
|
+
view: mat4x4f,
|
|
408
|
+
projection: mat4x4f,
|
|
409
|
+
viewPos: vec3f,
|
|
410
|
+
_padding: f32,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
struct MaterialUniforms {
|
|
414
|
+
edgeColor: vec4f,
|
|
415
|
+
edgeSize: f32,
|
|
416
|
+
isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
|
|
417
|
+
_padding1: f32,
|
|
418
|
+
_padding2: f32,
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
422
|
+
@group(0) @binding(1) var<uniform> material: MaterialUniforms;
|
|
423
|
+
@group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
|
|
424
|
+
|
|
425
|
+
struct VertexOutput {
|
|
426
|
+
@builtin(position) position: vec4f,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
@vertex fn vs(
|
|
430
|
+
@location(0) position: vec3f,
|
|
431
|
+
@location(1) normal: vec3f,
|
|
432
|
+
@location(3) joints0: vec4<u32>,
|
|
433
|
+
@location(4) weights0: vec4<f32>
|
|
434
|
+
) -> VertexOutput {
|
|
435
|
+
var output: VertexOutput;
|
|
436
|
+
let pos4 = vec4f(position, 1.0);
|
|
437
|
+
|
|
438
|
+
// Branchless weight normalization (avoids GPU branch divergence)
|
|
439
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
440
|
+
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
441
|
+
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
442
|
+
|
|
443
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
444
|
+
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
445
|
+
for (var i = 0u; i < 4u; i++) {
|
|
446
|
+
let j = joints0[i];
|
|
447
|
+
let w = normalizedWeights[i];
|
|
448
|
+
let m = skinMats[j];
|
|
449
|
+
skinnedPos += (m * pos4) * w;
|
|
450
|
+
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
451
|
+
skinnedNrm += (r3 * normal) * w;
|
|
452
|
+
}
|
|
453
|
+
let worldPos = skinnedPos.xyz;
|
|
454
|
+
let worldNormal = normalize(skinnedNrm);
|
|
455
|
+
|
|
456
|
+
// MMD invert hull: expand vertices outward along normals
|
|
457
|
+
let scaleFactor = 0.01;
|
|
458
|
+
let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
|
|
459
|
+
output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
|
|
460
|
+
return output;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@fragment fn fs() -> @location(0) vec4f {
|
|
464
|
+
var color = material.edgeColor;
|
|
465
|
+
|
|
466
|
+
if (material.isOverEyes > 0.5) {
|
|
467
|
+
color.a *= 0.5; // Hair outlines over eyes get 50% alpha
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return color;
|
|
471
|
+
}
|
|
472
|
+
`,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
this.outlinePipeline = this.device.createRenderPipeline({
|
|
476
|
+
label: "outline pipeline",
|
|
477
|
+
layout: outlinePipelineLayout,
|
|
478
|
+
vertex: {
|
|
479
|
+
module: outlineShaderModule,
|
|
480
|
+
buffers: [
|
|
481
|
+
{
|
|
482
|
+
arrayStride: 8 * 4,
|
|
483
|
+
attributes: [
|
|
484
|
+
{
|
|
485
|
+
shaderLocation: 0,
|
|
486
|
+
offset: 0,
|
|
487
|
+
format: "float32x3" as GPUVertexFormat,
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
shaderLocation: 1,
|
|
491
|
+
offset: 3 * 4,
|
|
492
|
+
format: "float32x3" as GPUVertexFormat,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
arrayStride: 4 * 2,
|
|
498
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
arrayStride: 4,
|
|
502
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
fragment: {
|
|
507
|
+
module: outlineShaderModule,
|
|
508
|
+
targets: [
|
|
509
|
+
{
|
|
510
|
+
format: this.presentationFormat,
|
|
511
|
+
blend: {
|
|
512
|
+
color: {
|
|
513
|
+
srcFactor: "src-alpha",
|
|
514
|
+
dstFactor: "one-minus-src-alpha",
|
|
515
|
+
operation: "add",
|
|
516
|
+
},
|
|
517
|
+
alpha: {
|
|
518
|
+
srcFactor: "one",
|
|
519
|
+
dstFactor: "one-minus-src-alpha",
|
|
520
|
+
operation: "add",
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
},
|
|
526
|
+
primitive: {
|
|
527
|
+
cullMode: "back",
|
|
528
|
+
},
|
|
529
|
+
depthStencil: {
|
|
530
|
+
format: "depth24plus-stencil8",
|
|
531
|
+
depthWriteEnabled: true,
|
|
532
|
+
depthCompare: "less-equal",
|
|
533
|
+
},
|
|
534
|
+
multisample: {
|
|
535
|
+
count: this.sampleCount,
|
|
536
|
+
},
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
// Hair outline pipeline
|
|
540
|
+
this.hairOutlinePipeline = this.device.createRenderPipeline({
|
|
541
|
+
label: "hair outline pipeline",
|
|
542
|
+
layout: outlinePipelineLayout,
|
|
543
|
+
vertex: {
|
|
544
|
+
module: outlineShaderModule,
|
|
545
|
+
buffers: [
|
|
546
|
+
{
|
|
547
|
+
arrayStride: 8 * 4,
|
|
548
|
+
attributes: [
|
|
549
|
+
{
|
|
550
|
+
shaderLocation: 0,
|
|
551
|
+
offset: 0,
|
|
552
|
+
format: "float32x3" as GPUVertexFormat,
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
shaderLocation: 1,
|
|
556
|
+
offset: 3 * 4,
|
|
557
|
+
format: "float32x3" as GPUVertexFormat,
|
|
558
|
+
},
|
|
559
|
+
],
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
arrayStride: 4 * 2,
|
|
563
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
arrayStride: 4,
|
|
567
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
fragment: {
|
|
572
|
+
module: outlineShaderModule,
|
|
573
|
+
targets: [
|
|
574
|
+
{
|
|
575
|
+
format: this.presentationFormat,
|
|
576
|
+
blend: {
|
|
577
|
+
color: {
|
|
578
|
+
srcFactor: "src-alpha",
|
|
579
|
+
dstFactor: "one-minus-src-alpha",
|
|
580
|
+
operation: "add",
|
|
581
|
+
},
|
|
582
|
+
alpha: {
|
|
583
|
+
srcFactor: "one",
|
|
584
|
+
dstFactor: "one-minus-src-alpha",
|
|
585
|
+
operation: "add",
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
},
|
|
591
|
+
primitive: {
|
|
592
|
+
cullMode: "back",
|
|
593
|
+
},
|
|
594
|
+
depthStencil: {
|
|
595
|
+
format: "depth24plus-stencil8",
|
|
596
|
+
depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
|
|
597
|
+
depthCompare: "less-equal", // Only draw where hair depth exists (no stencil test needed)
|
|
598
|
+
depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
|
|
599
|
+
depthBiasSlopeScale: 0.0,
|
|
600
|
+
depthBiasClamp: 0.0,
|
|
601
|
+
},
|
|
602
|
+
multisample: {
|
|
603
|
+
count: this.sampleCount,
|
|
604
|
+
},
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
// Eye overlay pipeline (renders after opaque, writes stencil)
|
|
608
|
+
this.eyePipeline = this.device.createRenderPipeline({
|
|
609
|
+
label: "eye overlay pipeline",
|
|
610
|
+
layout: mainPipelineLayout,
|
|
611
|
+
vertex: {
|
|
612
|
+
module: shaderModule,
|
|
613
|
+
buffers: [
|
|
614
|
+
{
|
|
615
|
+
arrayStride: 8 * 4,
|
|
616
|
+
attributes: [
|
|
617
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
618
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
619
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
|
|
620
|
+
],
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
arrayStride: 4 * 2,
|
|
624
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
arrayStride: 4,
|
|
628
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
},
|
|
632
|
+
fragment: {
|
|
633
|
+
module: shaderModule,
|
|
634
|
+
targets: [
|
|
635
|
+
{
|
|
636
|
+
format: this.presentationFormat,
|
|
637
|
+
blend: {
|
|
638
|
+
color: {
|
|
639
|
+
srcFactor: "src-alpha",
|
|
640
|
+
dstFactor: "one-minus-src-alpha",
|
|
641
|
+
operation: "add",
|
|
642
|
+
},
|
|
643
|
+
alpha: {
|
|
644
|
+
srcFactor: "one",
|
|
645
|
+
dstFactor: "one-minus-src-alpha",
|
|
646
|
+
operation: "add",
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
],
|
|
651
|
+
},
|
|
652
|
+
primitive: { cullMode: "front" },
|
|
653
|
+
depthStencil: {
|
|
654
|
+
format: "depth24plus-stencil8",
|
|
655
|
+
depthWriteEnabled: true, // Write depth to occlude back of head
|
|
656
|
+
depthCompare: "less-equal", // More lenient to reduce precision conflicts
|
|
657
|
+
depthBias: -0.00005, // Reduced bias to minimize conflicts while still occluding back face
|
|
658
|
+
depthBiasSlopeScale: 0.0,
|
|
659
|
+
depthBiasClamp: 0.0,
|
|
660
|
+
stencilFront: {
|
|
661
|
+
compare: "always",
|
|
662
|
+
failOp: "keep",
|
|
663
|
+
depthFailOp: "keep",
|
|
664
|
+
passOp: "replace", // Write stencil value 1
|
|
665
|
+
},
|
|
666
|
+
stencilBack: {
|
|
667
|
+
compare: "always",
|
|
668
|
+
failOp: "keep",
|
|
669
|
+
depthFailOp: "keep",
|
|
670
|
+
passOp: "replace",
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
multisample: { count: this.sampleCount },
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
// Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
|
|
677
|
+
const depthOnlyShaderModule = this.device.createShaderModule({
|
|
678
|
+
label: "depth only shader",
|
|
679
|
+
code: /* wgsl */ `
|
|
680
|
+
struct CameraUniforms {
|
|
681
|
+
view: mat4x4f,
|
|
682
|
+
projection: mat4x4f,
|
|
683
|
+
viewPos: vec3f,
|
|
684
|
+
_padding: f32,
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
688
|
+
@group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
|
|
689
|
+
|
|
690
|
+
@vertex fn vs(
|
|
691
|
+
@location(0) position: vec3f,
|
|
692
|
+
@location(1) normal: vec3f,
|
|
693
|
+
@location(3) joints0: vec4<u32>,
|
|
694
|
+
@location(4) weights0: vec4<f32>
|
|
695
|
+
) -> @builtin(position) vec4f {
|
|
696
|
+
let pos4 = vec4f(position, 1.0);
|
|
697
|
+
|
|
698
|
+
// Branchless weight normalization (avoids GPU branch divergence)
|
|
699
|
+
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
700
|
+
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
701
|
+
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
702
|
+
|
|
703
|
+
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
704
|
+
for (var i = 0u; i < 4u; i++) {
|
|
705
|
+
let j = joints0[i];
|
|
706
|
+
let w = normalizedWeights[i];
|
|
707
|
+
let m = skinMats[j];
|
|
708
|
+
skinnedPos += (m * pos4) * w;
|
|
709
|
+
}
|
|
710
|
+
let worldPos = skinnedPos.xyz;
|
|
711
|
+
let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
712
|
+
return clipPos;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
@fragment fn fs() -> @location(0) vec4f {
|
|
716
|
+
return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
|
|
717
|
+
}
|
|
718
|
+
`,
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
// Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
|
|
722
|
+
this.hairDepthPipeline = this.device.createRenderPipeline({
|
|
723
|
+
label: "hair depth pre-pass",
|
|
724
|
+
layout: mainPipelineLayout,
|
|
725
|
+
vertex: {
|
|
726
|
+
module: depthOnlyShaderModule,
|
|
727
|
+
buffers: [
|
|
728
|
+
{
|
|
729
|
+
arrayStride: 8 * 4,
|
|
730
|
+
attributes: [
|
|
731
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
732
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
733
|
+
],
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
arrayStride: 4 * 2,
|
|
737
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
arrayStride: 4,
|
|
741
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
742
|
+
},
|
|
743
|
+
],
|
|
744
|
+
},
|
|
745
|
+
fragment: {
|
|
746
|
+
module: depthOnlyShaderModule,
|
|
747
|
+
entryPoint: "fs",
|
|
748
|
+
targets: [
|
|
749
|
+
{
|
|
750
|
+
format: this.presentationFormat,
|
|
751
|
+
writeMask: 0, // Disable all color writes - we only care about depth
|
|
752
|
+
},
|
|
753
|
+
],
|
|
754
|
+
},
|
|
755
|
+
primitive: { cullMode: "front" },
|
|
756
|
+
depthStencil: {
|
|
757
|
+
format: "depth24plus-stencil8",
|
|
758
|
+
depthWriteEnabled: true,
|
|
759
|
+
depthCompare: "less-equal", // Match the color pass compare mode for consistency
|
|
760
|
+
depthBias: 0.0,
|
|
761
|
+
depthBiasSlopeScale: 0.0,
|
|
762
|
+
depthBiasClamp: 0.0,
|
|
763
|
+
},
|
|
764
|
+
multisample: { count: this.sampleCount },
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
// Hair pipeline for rendering over eyes (stencil == 1)
|
|
768
|
+
this.hairPipelineOverEyes = this.device.createRenderPipeline({
|
|
769
|
+
label: "hair pipeline (over eyes)",
|
|
770
|
+
layout: mainPipelineLayout,
|
|
771
|
+
vertex: {
|
|
772
|
+
module: shaderModule,
|
|
773
|
+
buffers: [
|
|
774
|
+
{
|
|
775
|
+
arrayStride: 8 * 4,
|
|
776
|
+
attributes: [
|
|
777
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
778
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
779
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
|
|
780
|
+
],
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
arrayStride: 4 * 2,
|
|
784
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
arrayStride: 4,
|
|
788
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
},
|
|
792
|
+
fragment: {
|
|
793
|
+
module: shaderModule,
|
|
794
|
+
targets: [
|
|
795
|
+
{
|
|
796
|
+
format: this.presentationFormat,
|
|
797
|
+
blend: {
|
|
798
|
+
color: {
|
|
799
|
+
srcFactor: "src-alpha",
|
|
800
|
+
dstFactor: "one-minus-src-alpha",
|
|
801
|
+
operation: "add",
|
|
802
|
+
},
|
|
803
|
+
alpha: {
|
|
804
|
+
srcFactor: "one",
|
|
805
|
+
dstFactor: "one-minus-src-alpha",
|
|
806
|
+
operation: "add",
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
},
|
|
812
|
+
primitive: { cullMode: "front" },
|
|
813
|
+
depthStencil: {
|
|
814
|
+
format: "depth24plus-stencil8",
|
|
815
|
+
depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
|
|
816
|
+
depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
|
|
817
|
+
stencilFront: {
|
|
818
|
+
compare: "equal", // Only render where stencil == 1 (over eyes)
|
|
819
|
+
failOp: "keep",
|
|
820
|
+
depthFailOp: "keep",
|
|
821
|
+
passOp: "keep",
|
|
822
|
+
},
|
|
823
|
+
stencilBack: {
|
|
824
|
+
compare: "equal",
|
|
825
|
+
failOp: "keep",
|
|
826
|
+
depthFailOp: "keep",
|
|
827
|
+
passOp: "keep",
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
multisample: { count: this.sampleCount },
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
// Hair pipeline for rendering over non-eyes (stencil != 1)
|
|
834
|
+
this.hairPipelineOverNonEyes = this.device.createRenderPipeline({
|
|
835
|
+
label: "hair pipeline (over non-eyes)",
|
|
836
|
+
layout: mainPipelineLayout,
|
|
837
|
+
vertex: {
|
|
838
|
+
module: shaderModule,
|
|
839
|
+
buffers: [
|
|
840
|
+
{
|
|
841
|
+
arrayStride: 8 * 4,
|
|
842
|
+
attributes: [
|
|
843
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
|
|
844
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
|
|
845
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
|
|
846
|
+
],
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
arrayStride: 4 * 2,
|
|
850
|
+
attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
arrayStride: 4,
|
|
854
|
+
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
|
|
855
|
+
},
|
|
856
|
+
],
|
|
857
|
+
},
|
|
858
|
+
fragment: {
|
|
859
|
+
module: shaderModule,
|
|
860
|
+
targets: [
|
|
861
|
+
{
|
|
862
|
+
format: this.presentationFormat,
|
|
863
|
+
blend: {
|
|
864
|
+
color: {
|
|
865
|
+
srcFactor: "src-alpha",
|
|
866
|
+
dstFactor: "one-minus-src-alpha",
|
|
867
|
+
operation: "add",
|
|
868
|
+
},
|
|
869
|
+
alpha: {
|
|
870
|
+
srcFactor: "one",
|
|
871
|
+
dstFactor: "one-minus-src-alpha",
|
|
872
|
+
operation: "add",
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
],
|
|
877
|
+
},
|
|
878
|
+
primitive: { cullMode: "front" },
|
|
879
|
+
depthStencil: {
|
|
880
|
+
format: "depth24plus-stencil8",
|
|
881
|
+
depthWriteEnabled: false, // Don't write depth (already written in pre-pass)
|
|
882
|
+
depthCompare: "less-equal", // More lenient than "equal" to avoid precision issues with MSAA
|
|
883
|
+
stencilFront: {
|
|
884
|
+
compare: "not-equal", // Only render where stencil != 1 (over non-eyes)
|
|
885
|
+
failOp: "keep",
|
|
886
|
+
depthFailOp: "keep",
|
|
887
|
+
passOp: "keep",
|
|
888
|
+
},
|
|
889
|
+
stencilBack: {
|
|
890
|
+
compare: "not-equal",
|
|
891
|
+
failOp: "keep",
|
|
892
|
+
depthFailOp: "keep",
|
|
893
|
+
passOp: "keep",
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
multisample: { count: this.sampleCount },
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Create compute shader for skin matrix computation
|
|
901
|
+
private createSkinMatrixComputePipeline() {
|
|
902
|
+
const computeShader = this.device.createShaderModule({
|
|
903
|
+
label: "skin matrix compute",
|
|
904
|
+
code: /* wgsl */ `
|
|
905
|
+
struct BoneCountUniform {
|
|
906
|
+
count: u32,
|
|
907
|
+
_padding1: u32,
|
|
908
|
+
_padding2: u32,
|
|
909
|
+
_padding3: u32,
|
|
910
|
+
_padding4: vec4<u32>,
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
@group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
|
|
914
|
+
@group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
|
|
915
|
+
@group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
|
|
916
|
+
@group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
|
|
917
|
+
|
|
918
|
+
@compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
|
|
919
|
+
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
|
920
|
+
let boneIndex = globalId.x;
|
|
921
|
+
if (boneIndex >= boneCount.count) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
let worldMat = worldMatrices[boneIndex];
|
|
925
|
+
let invBindMat = inverseBindMatrices[boneIndex];
|
|
926
|
+
skinMatrices[boneIndex] = worldMat * invBindMat;
|
|
927
|
+
}
|
|
928
|
+
`,
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
this.skinMatrixComputePipeline = this.device.createComputePipeline({
|
|
932
|
+
label: "skin matrix compute pipeline",
|
|
933
|
+
layout: "auto",
|
|
934
|
+
compute: {
|
|
935
|
+
module: computeShader,
|
|
936
|
+
},
|
|
937
|
+
})
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Create fullscreen quad for post-processing
|
|
941
|
+
private createFullscreenQuad() {
|
|
942
|
+
// Fullscreen quad vertices: two triangles covering the entire screen - Format: position (x, y), uv (u, v)
|
|
943
|
+
const quadVertices = new Float32Array([
|
|
944
|
+
// Triangle 1
|
|
945
|
+
-1.0,
|
|
946
|
+
-1.0,
|
|
947
|
+
0.0,
|
|
948
|
+
0.0, // bottom-left
|
|
949
|
+
1.0,
|
|
950
|
+
-1.0,
|
|
951
|
+
1.0,
|
|
952
|
+
0.0, // bottom-right
|
|
953
|
+
-1.0,
|
|
954
|
+
1.0,
|
|
955
|
+
0.0,
|
|
956
|
+
1.0, // top-left
|
|
957
|
+
// Triangle 2
|
|
958
|
+
-1.0,
|
|
959
|
+
1.0,
|
|
960
|
+
0.0,
|
|
961
|
+
1.0, // top-left
|
|
962
|
+
1.0,
|
|
963
|
+
-1.0,
|
|
964
|
+
1.0,
|
|
965
|
+
0.0, // bottom-right
|
|
966
|
+
1.0,
|
|
967
|
+
1.0,
|
|
968
|
+
1.0,
|
|
969
|
+
1.0, // top-right
|
|
970
|
+
])
|
|
971
|
+
|
|
972
|
+
this.fullscreenQuadBuffer = this.device.createBuffer({
|
|
973
|
+
label: "fullscreen quad",
|
|
974
|
+
size: quadVertices.byteLength,
|
|
975
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
976
|
+
})
|
|
977
|
+
this.device.queue.writeBuffer(this.fullscreenQuadBuffer, 0, quadVertices)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Create bloom post-processing pipelines
|
|
981
|
+
private createBloomPipelines() {
|
|
982
|
+
// Bloom extraction shader (extracts bright areas)
|
|
983
|
+
const bloomExtractShader = this.device.createShaderModule({
|
|
984
|
+
label: "bloom extract",
|
|
985
|
+
code: /* wgsl */ `
|
|
986
|
+
struct VertexOutput {
|
|
987
|
+
@builtin(position) position: vec4f,
|
|
988
|
+
@location(0) uv: vec2f,
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
992
|
+
var output: VertexOutput;
|
|
993
|
+
// Generate fullscreen quad from vertex index
|
|
994
|
+
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
995
|
+
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
996
|
+
output.position = vec4f(x, y, 0.0, 1.0);
|
|
997
|
+
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
998
|
+
return output;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
struct BloomExtractUniforms {
|
|
1002
|
+
threshold: f32,
|
|
1003
|
+
_padding1: f32,
|
|
1004
|
+
_padding2: f32,
|
|
1005
|
+
_padding3: f32,
|
|
1006
|
+
_padding4: f32,
|
|
1007
|
+
_padding5: f32,
|
|
1008
|
+
_padding6: f32,
|
|
1009
|
+
_padding7: f32,
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
1013
|
+
@group(0) @binding(1) var inputSampler: sampler;
|
|
1014
|
+
@group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
|
|
1015
|
+
|
|
1016
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
1017
|
+
let color = textureSample(inputTexture, inputSampler, input.uv);
|
|
1018
|
+
// Extract bright areas above threshold
|
|
1019
|
+
let threshold = extractUniforms.threshold;
|
|
1020
|
+
let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
|
|
1021
|
+
return vec4f(bloom, color.a);
|
|
1022
|
+
}
|
|
1023
|
+
`,
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
// Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
|
|
1027
|
+
const bloomBlurShader = this.device.createShaderModule({
|
|
1028
|
+
label: "bloom blur",
|
|
1029
|
+
code: /* wgsl */ `
|
|
1030
|
+
struct VertexOutput {
|
|
1031
|
+
@builtin(position) position: vec4f,
|
|
1032
|
+
@location(0) uv: vec2f,
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
1036
|
+
var output: VertexOutput;
|
|
1037
|
+
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
1038
|
+
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
1039
|
+
output.position = vec4f(x, y, 0.0, 1.0);
|
|
1040
|
+
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
1041
|
+
return output;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
struct BlurUniforms {
|
|
1045
|
+
direction: vec2f,
|
|
1046
|
+
_padding1: f32,
|
|
1047
|
+
_padding2: f32,
|
|
1048
|
+
_padding3: f32,
|
|
1049
|
+
_padding4: f32,
|
|
1050
|
+
_padding5: f32,
|
|
1051
|
+
_padding6: f32,
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
1055
|
+
@group(0) @binding(1) var inputSampler: sampler;
|
|
1056
|
+
@group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
|
|
1057
|
+
|
|
1058
|
+
// 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
|
|
1059
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
1060
|
+
let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
|
|
1061
|
+
|
|
1062
|
+
// Bilinear optimization: leverage hardware filtering to sample between pixels
|
|
1063
|
+
// Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
|
|
1064
|
+
// Optimized 3-tap: combine adjacent samples using weighted offsets
|
|
1065
|
+
let weight0 = 0.38774; // Center sample
|
|
1066
|
+
let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
|
|
1067
|
+
let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
|
|
1068
|
+
|
|
1069
|
+
var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
|
|
1070
|
+
let offsetVec = offset1 * texelSize * blurUniforms.direction;
|
|
1071
|
+
result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
|
|
1072
|
+
result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
|
|
1073
|
+
|
|
1074
|
+
return result;
|
|
1075
|
+
}
|
|
1076
|
+
`,
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
// Bloom composition shader (combines original scene with bloom)
|
|
1080
|
+
const bloomComposeShader = this.device.createShaderModule({
|
|
1081
|
+
label: "bloom compose",
|
|
1082
|
+
code: /* wgsl */ `
|
|
1083
|
+
struct VertexOutput {
|
|
1084
|
+
@builtin(position) position: vec4f,
|
|
1085
|
+
@location(0) uv: vec2f,
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
@vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
1089
|
+
var output: VertexOutput;
|
|
1090
|
+
let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
|
|
1091
|
+
let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
|
|
1092
|
+
output.position = vec4f(x, y, 0.0, 1.0);
|
|
1093
|
+
output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
|
|
1094
|
+
return output;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
struct BloomComposeUniforms {
|
|
1098
|
+
intensity: f32,
|
|
1099
|
+
_padding1: f32,
|
|
1100
|
+
_padding2: f32,
|
|
1101
|
+
_padding3: f32,
|
|
1102
|
+
_padding4: f32,
|
|
1103
|
+
_padding5: f32,
|
|
1104
|
+
_padding6: f32,
|
|
1105
|
+
_padding7: f32,
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
@group(0) @binding(0) var sceneTexture: texture_2d<f32>;
|
|
1109
|
+
@group(0) @binding(1) var sceneSampler: sampler;
|
|
1110
|
+
@group(0) @binding(2) var bloomTexture: texture_2d<f32>;
|
|
1111
|
+
@group(0) @binding(3) var bloomSampler: sampler;
|
|
1112
|
+
@group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
|
|
1113
|
+
|
|
1114
|
+
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
1115
|
+
let scene = textureSample(sceneTexture, sceneSampler, input.uv);
|
|
1116
|
+
let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
|
|
1117
|
+
// Additive blending with intensity control
|
|
1118
|
+
let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
|
|
1119
|
+
return vec4f(result, scene.a);
|
|
1120
|
+
}
|
|
1121
|
+
`,
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
// Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
|
|
1125
|
+
const blurDirectionBuffer = this.device.createBuffer({
|
|
1126
|
+
label: "blur direction",
|
|
1127
|
+
size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
|
|
1128
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
// Create uniform buffer for bloom intensity (minimum 32 bytes for WebGPU)
|
|
1132
|
+
const bloomIntensityBuffer = this.device.createBuffer({
|
|
1133
|
+
label: "bloom intensity",
|
|
1134
|
+
size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
|
|
1135
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
// Create uniform buffer for bloom threshold (minimum 32 bytes for WebGPU)
|
|
1139
|
+
const bloomThresholdBuffer = this.device.createBuffer({
|
|
1140
|
+
label: "bloom threshold",
|
|
1141
|
+
size: 32, // Minimum 32 bytes required for uniform buffers in WebGPU
|
|
1142
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
// Set default bloom values
|
|
1146
|
+
const intensityData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
|
|
1147
|
+
intensityData[0] = this.bloomIntensity
|
|
1148
|
+
this.device.queue.writeBuffer(bloomIntensityBuffer, 0, intensityData)
|
|
1149
|
+
|
|
1150
|
+
const thresholdData = new Float32Array(8) // f32 + 7 padding floats = 8 floats = 32 bytes
|
|
1151
|
+
thresholdData[0] = this.bloomThreshold
|
|
1152
|
+
this.device.queue.writeBuffer(bloomThresholdBuffer, 0, thresholdData)
|
|
1153
|
+
|
|
1154
|
+
// Create linear sampler for post-processing
|
|
1155
|
+
const linearSampler = this.device.createSampler({
|
|
1156
|
+
magFilter: "linear",
|
|
1157
|
+
minFilter: "linear",
|
|
1158
|
+
addressModeU: "clamp-to-edge",
|
|
1159
|
+
addressModeV: "clamp-to-edge",
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
// Bloom extraction pipeline
|
|
1163
|
+
this.bloomExtractPipeline = this.device.createRenderPipeline({
|
|
1164
|
+
label: "bloom extract",
|
|
1165
|
+
layout: "auto",
|
|
1166
|
+
vertex: {
|
|
1167
|
+
module: bloomExtractShader,
|
|
1168
|
+
entryPoint: "vs",
|
|
1169
|
+
},
|
|
1170
|
+
fragment: {
|
|
1171
|
+
module: bloomExtractShader,
|
|
1172
|
+
entryPoint: "fs",
|
|
1173
|
+
targets: [{ format: this.presentationFormat }],
|
|
1174
|
+
},
|
|
1175
|
+
primitive: { topology: "triangle-list" },
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
// Bloom blur pipeline
|
|
1179
|
+
this.bloomBlurPipeline = this.device.createRenderPipeline({
|
|
1180
|
+
label: "bloom blur",
|
|
1181
|
+
layout: "auto",
|
|
1182
|
+
vertex: {
|
|
1183
|
+
module: bloomBlurShader,
|
|
1184
|
+
entryPoint: "vs",
|
|
1185
|
+
},
|
|
1186
|
+
fragment: {
|
|
1187
|
+
module: bloomBlurShader,
|
|
1188
|
+
entryPoint: "fs",
|
|
1189
|
+
targets: [{ format: this.presentationFormat }],
|
|
1190
|
+
},
|
|
1191
|
+
primitive: { topology: "triangle-list" },
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
// Bloom composition pipeline
|
|
1195
|
+
this.bloomComposePipeline = this.device.createRenderPipeline({
|
|
1196
|
+
label: "bloom compose",
|
|
1197
|
+
layout: "auto",
|
|
1198
|
+
vertex: {
|
|
1199
|
+
module: bloomComposeShader,
|
|
1200
|
+
entryPoint: "vs",
|
|
1201
|
+
},
|
|
1202
|
+
fragment: {
|
|
1203
|
+
module: bloomComposeShader,
|
|
1204
|
+
entryPoint: "fs",
|
|
1205
|
+
targets: [{ format: this.presentationFormat }],
|
|
1206
|
+
},
|
|
1207
|
+
primitive: { topology: "triangle-list" },
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
// Store buffers and sampler for later use
|
|
1211
|
+
this.blurDirectionBuffer = blurDirectionBuffer
|
|
1212
|
+
this.bloomIntensityBuffer = bloomIntensityBuffer
|
|
1213
|
+
this.bloomThresholdBuffer = bloomThresholdBuffer
|
|
1214
|
+
this.linearSampler = linearSampler
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private setupBloom(width: number, height: number) {
|
|
1218
|
+
const bloomWidth = Math.floor(width / this.BLOOM_DOWNSCALE_FACTOR)
|
|
1219
|
+
const bloomHeight = Math.floor(height / this.BLOOM_DOWNSCALE_FACTOR)
|
|
1220
|
+
this.bloomExtractTexture = this.device.createTexture({
|
|
1221
|
+
label: "bloom extract",
|
|
1222
|
+
size: [bloomWidth, bloomHeight],
|
|
1223
|
+
format: this.presentationFormat,
|
|
1224
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1225
|
+
})
|
|
1226
|
+
this.bloomBlurTexture1 = this.device.createTexture({
|
|
1227
|
+
label: "bloom blur 1",
|
|
1228
|
+
size: [bloomWidth, bloomHeight],
|
|
1229
|
+
format: this.presentationFormat,
|
|
1230
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1231
|
+
})
|
|
1232
|
+
this.bloomBlurTexture2 = this.device.createTexture({
|
|
1233
|
+
label: "bloom blur 2",
|
|
1234
|
+
size: [bloomWidth, bloomHeight],
|
|
1235
|
+
format: this.presentationFormat,
|
|
1236
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
// Create bloom bind groups
|
|
1240
|
+
this.bloomExtractBindGroup = this.device.createBindGroup({
|
|
1241
|
+
layout: this.bloomExtractPipeline.getBindGroupLayout(0),
|
|
1242
|
+
entries: [
|
|
1243
|
+
{ binding: 0, resource: this.sceneRenderTexture.createView() },
|
|
1244
|
+
{ binding: 1, resource: this.linearSampler },
|
|
1245
|
+
{ binding: 2, resource: { buffer: this.bloomThresholdBuffer } },
|
|
1246
|
+
],
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
this.bloomBlurHBindGroup = this.device.createBindGroup({
|
|
1250
|
+
layout: this.bloomBlurPipeline.getBindGroupLayout(0),
|
|
1251
|
+
entries: [
|
|
1252
|
+
{ binding: 0, resource: this.bloomExtractTexture.createView() },
|
|
1253
|
+
{ binding: 1, resource: this.linearSampler },
|
|
1254
|
+
{ binding: 2, resource: { buffer: this.blurDirectionBuffer } },
|
|
1255
|
+
],
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
this.bloomBlurVBindGroup = this.device.createBindGroup({
|
|
1259
|
+
layout: this.bloomBlurPipeline.getBindGroupLayout(0),
|
|
1260
|
+
entries: [
|
|
1261
|
+
{ binding: 0, resource: this.bloomBlurTexture1.createView() },
|
|
1262
|
+
{ binding: 1, resource: this.linearSampler },
|
|
1263
|
+
{ binding: 2, resource: { buffer: this.blurDirectionBuffer } },
|
|
1264
|
+
],
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
this.bloomComposeBindGroup = this.device.createBindGroup({
|
|
1268
|
+
layout: this.bloomComposePipeline.getBindGroupLayout(0),
|
|
1269
|
+
entries: [
|
|
1270
|
+
{ binding: 0, resource: this.sceneRenderTexture.createView() },
|
|
1271
|
+
{ binding: 1, resource: this.linearSampler },
|
|
1272
|
+
{ binding: 2, resource: this.bloomBlurTexture2.createView() },
|
|
1273
|
+
{ binding: 3, resource: this.linearSampler },
|
|
1274
|
+
{ binding: 4, resource: { buffer: this.bloomIntensityBuffer } },
|
|
1275
|
+
],
|
|
1276
|
+
})
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Step 3: Setup canvas resize handling
|
|
1280
|
+
private setupResize() {
|
|
1281
|
+
this.resizeObserver = new ResizeObserver(() => this.handleResize())
|
|
1282
|
+
this.resizeObserver.observe(this.canvas)
|
|
1283
|
+
this.handleResize()
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
private handleResize() {
|
|
1287
|
+
const displayWidth = this.canvas.clientWidth
|
|
1288
|
+
const displayHeight = this.canvas.clientHeight
|
|
1289
|
+
|
|
1290
|
+
const dpr = window.devicePixelRatio || 1
|
|
1291
|
+
const width = Math.floor(displayWidth * dpr)
|
|
1292
|
+
const height = Math.floor(displayHeight * dpr)
|
|
1293
|
+
|
|
1294
|
+
if (!this.multisampleTexture || this.canvas.width !== width || this.canvas.height !== height) {
|
|
1295
|
+
this.canvas.width = width
|
|
1296
|
+
this.canvas.height = height
|
|
1297
|
+
|
|
1298
|
+
this.multisampleTexture = this.device.createTexture({
|
|
1299
|
+
label: "multisample render target",
|
|
1300
|
+
size: [width, height],
|
|
1301
|
+
sampleCount: this.sampleCount,
|
|
1302
|
+
format: this.presentationFormat,
|
|
1303
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
this.depthTexture = this.device.createTexture({
|
|
1307
|
+
label: "depth texture",
|
|
1308
|
+
size: [width, height],
|
|
1309
|
+
sampleCount: this.sampleCount,
|
|
1310
|
+
format: "depth24plus-stencil8",
|
|
1311
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
// Create scene render texture (non-multisampled for post-processing)
|
|
1315
|
+
this.sceneRenderTexture = this.device.createTexture({
|
|
1316
|
+
label: "scene render texture",
|
|
1317
|
+
size: [width, height],
|
|
1318
|
+
format: this.presentationFormat,
|
|
1319
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1320
|
+
})
|
|
1321
|
+
this.sceneRenderTextureView = this.sceneRenderTexture.createView()
|
|
1322
|
+
|
|
1323
|
+
// Setup bloom textures and bind groups
|
|
1324
|
+
this.setupBloom(width, height)
|
|
1325
|
+
|
|
1326
|
+
const depthTextureView = this.depthTexture.createView()
|
|
1327
|
+
|
|
1328
|
+
// Render scene to texture instead of directly to canvas
|
|
1329
|
+
const colorAttachment: GPURenderPassColorAttachment =
|
|
1330
|
+
this.sampleCount > 1
|
|
1331
|
+
? {
|
|
1332
|
+
view: this.multisampleTexture.createView(),
|
|
1333
|
+
resolveTarget: this.sceneRenderTextureView,
|
|
1334
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1335
|
+
loadOp: "clear",
|
|
1336
|
+
storeOp: "store",
|
|
1337
|
+
}
|
|
1338
|
+
: {
|
|
1339
|
+
view: this.sceneRenderTextureView,
|
|
1340
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1341
|
+
loadOp: "clear",
|
|
1342
|
+
storeOp: "store",
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
this.renderPassDescriptor = {
|
|
1346
|
+
label: "renderPass",
|
|
1347
|
+
colorAttachments: [colorAttachment],
|
|
1348
|
+
depthStencilAttachment: {
|
|
1349
|
+
view: depthTextureView,
|
|
1350
|
+
depthClearValue: 1.0,
|
|
1351
|
+
depthLoadOp: "clear",
|
|
1352
|
+
depthStoreOp: "store",
|
|
1353
|
+
stencilClearValue: 0,
|
|
1354
|
+
stencilLoadOp: "clear",
|
|
1355
|
+
stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
|
|
1356
|
+
},
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
this.camera.aspect = width / height
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Step 4: Create camera and uniform buffer
|
|
1364
|
+
private setupCamera() {
|
|
1365
|
+
this.cameraUniformBuffer = this.device.createBuffer({
|
|
1366
|
+
label: "camera uniforms",
|
|
1367
|
+
size: 40 * 4,
|
|
1368
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraDistance, this.cameraTarget)
|
|
1372
|
+
|
|
1373
|
+
this.camera.aspect = this.canvas.width / this.canvas.height
|
|
1374
|
+
this.camera.attachControl(this.canvas)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Step 5: Create lighting buffers
|
|
1378
|
+
private setupLighting() {
|
|
1379
|
+
this.lightUniformBuffer = this.device.createBuffer({
|
|
1380
|
+
label: "light uniforms",
|
|
1381
|
+
size: 64 * 4,
|
|
1382
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1383
|
+
})
|
|
1384
|
+
|
|
1385
|
+
this.lightCount = 0
|
|
1386
|
+
|
|
1387
|
+
this.setAmbient(this.ambient)
|
|
1388
|
+
this.addLight(new Vec3(-0.5, -0.8, 0.5).normalize(), new Vec3(1.0, 0.95, 0.9), 0.02)
|
|
1389
|
+
this.addLight(new Vec3(0.7, -0.5, 0.3).normalize(), new Vec3(0.8, 0.85, 1.0), 0.015)
|
|
1390
|
+
this.addLight(new Vec3(0.3, -0.5, -1.0).normalize(), new Vec3(0.9, 0.9, 1.0), 0.01)
|
|
1391
|
+
this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
private addLight(direction: Vec3, color: Vec3, intensity: number = 1.0): boolean {
|
|
1395
|
+
if (this.lightCount >= 4) return false
|
|
1396
|
+
|
|
1397
|
+
const normalized = direction.normalize()
|
|
1398
|
+
const baseIndex = 4 + this.lightCount * 8
|
|
1399
|
+
this.lightData[baseIndex] = normalized.x
|
|
1400
|
+
this.lightData[baseIndex + 1] = normalized.y
|
|
1401
|
+
this.lightData[baseIndex + 2] = normalized.z
|
|
1402
|
+
this.lightData[baseIndex + 3] = 0
|
|
1403
|
+
this.lightData[baseIndex + 4] = color.x
|
|
1404
|
+
this.lightData[baseIndex + 5] = color.y
|
|
1405
|
+
this.lightData[baseIndex + 6] = color.z
|
|
1406
|
+
this.lightData[baseIndex + 7] = intensity
|
|
1407
|
+
|
|
1408
|
+
this.lightCount++
|
|
1409
|
+
this.lightData[1] = this.lightCount
|
|
1410
|
+
return true
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
private setAmbient(intensity: number) {
|
|
1414
|
+
this.lightData[0] = intensity
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
public async loadAnimation(url: string) {
|
|
1418
|
+
const frames = await VMDLoader.load(url)
|
|
1419
|
+
this.animationFrames = frames
|
|
1420
|
+
this.hasAnimation = true
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
public playAnimation() {
|
|
1424
|
+
if (this.animationFrames.length === 0) return
|
|
1425
|
+
|
|
1426
|
+
this.stopAnimation()
|
|
1427
|
+
this.playingAnimation = true
|
|
1428
|
+
|
|
1429
|
+
const allBoneKeyFrames: BoneKeyFrame[] = []
|
|
1430
|
+
for (const keyFrame of this.animationFrames) {
|
|
1431
|
+
for (const boneFrame of keyFrame.boneFrames) {
|
|
1432
|
+
allBoneKeyFrames.push({
|
|
1433
|
+
boneName: boneFrame.boneName,
|
|
1434
|
+
time: keyFrame.time,
|
|
1435
|
+
rotation: boneFrame.rotation,
|
|
1436
|
+
})
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const boneKeyFramesByBone = new Map<string, BoneKeyFrame[]>()
|
|
1441
|
+
for (const boneKeyFrame of allBoneKeyFrames) {
|
|
1442
|
+
if (!boneKeyFramesByBone.has(boneKeyFrame.boneName)) {
|
|
1443
|
+
boneKeyFramesByBone.set(boneKeyFrame.boneName, [])
|
|
1444
|
+
}
|
|
1445
|
+
boneKeyFramesByBone.get(boneKeyFrame.boneName)!.push(boneKeyFrame)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
for (const keyFrames of boneKeyFramesByBone.values()) {
|
|
1449
|
+
keyFrames.sort((a, b) => a.time - b.time)
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const time0Rotations: Array<{ boneName: string; rotation: Quat }> = []
|
|
1453
|
+
const bonesWithTime0 = new Set<string>()
|
|
1454
|
+
for (const [boneName, keyFrames] of boneKeyFramesByBone.entries()) {
|
|
1455
|
+
if (keyFrames.length > 0 && keyFrames[0].time === 0) {
|
|
1456
|
+
time0Rotations.push({
|
|
1457
|
+
boneName: boneName,
|
|
1458
|
+
rotation: keyFrames[0].rotation,
|
|
1459
|
+
})
|
|
1460
|
+
bonesWithTime0.add(boneName)
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (this.currentModel) {
|
|
1465
|
+
if (time0Rotations.length > 0) {
|
|
1466
|
+
const boneNames = time0Rotations.map((r) => r.boneName)
|
|
1467
|
+
const rotations = time0Rotations.map((r) => r.rotation)
|
|
1468
|
+
this.rotateBones(boneNames, rotations, 0)
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const skeleton = this.currentModel.getSkeleton()
|
|
1472
|
+
const bonesToReset: string[] = []
|
|
1473
|
+
for (const bone of skeleton.bones) {
|
|
1474
|
+
if (!bonesWithTime0.has(bone.name)) {
|
|
1475
|
+
bonesToReset.push(bone.name)
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if (bonesToReset.length > 0) {
|
|
1480
|
+
const identityQuat = new Quat(0, 0, 0, 1)
|
|
1481
|
+
const identityQuats = new Array(bonesToReset.length).fill(identityQuat)
|
|
1482
|
+
this.rotateBones(bonesToReset, identityQuats, 0)
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// Reset physics immediately and upload matrices to prevent A-pose flash
|
|
1486
|
+
if (this.physics) {
|
|
1487
|
+
this.currentModel.evaluatePose()
|
|
1488
|
+
|
|
1489
|
+
const worldMats = this.currentModel.getBoneWorldMatrices()
|
|
1490
|
+
this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices())
|
|
1491
|
+
|
|
1492
|
+
// Upload matrices immediately so next frame shows correct pose
|
|
1493
|
+
this.device.queue.writeBuffer(
|
|
1494
|
+
this.worldMatrixBuffer!,
|
|
1495
|
+
0,
|
|
1496
|
+
worldMats.buffer,
|
|
1497
|
+
worldMats.byteOffset,
|
|
1498
|
+
worldMats.byteLength
|
|
1499
|
+
)
|
|
1500
|
+
const encoder = this.device.createCommandEncoder()
|
|
1501
|
+
this.computeSkinMatrices(encoder)
|
|
1502
|
+
this.device.queue.submit([encoder.finish()])
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
|
|
1506
|
+
for (let i = 0; i < keyFrames.length; i++) {
|
|
1507
|
+
const boneKeyFrame = keyFrames[i]
|
|
1508
|
+
const previousBoneKeyFrame = i > 0 ? keyFrames[i - 1] : null
|
|
1509
|
+
|
|
1510
|
+
if (boneKeyFrame.time === 0) continue
|
|
1511
|
+
|
|
1512
|
+
let durationMs = 0
|
|
1513
|
+
if (i === 0) {
|
|
1514
|
+
durationMs = boneKeyFrame.time * 1000
|
|
1515
|
+
} else if (previousBoneKeyFrame) {
|
|
1516
|
+
durationMs = (boneKeyFrame.time - previousBoneKeyFrame.time) * 1000
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const scheduleTime = i > 0 && previousBoneKeyFrame ? previousBoneKeyFrame.time : 0
|
|
1520
|
+
const delayMs = scheduleTime * 1000
|
|
1521
|
+
|
|
1522
|
+
if (delayMs <= 0) {
|
|
1523
|
+
this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs)
|
|
1524
|
+
} else {
|
|
1525
|
+
const timeoutId = window.setTimeout(() => {
|
|
1526
|
+
this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs)
|
|
1527
|
+
}, delayMs)
|
|
1528
|
+
this.animationTimeouts.push(timeoutId)
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
public stopAnimation() {
|
|
1535
|
+
for (const timeoutId of this.animationTimeouts) {
|
|
1536
|
+
clearTimeout(timeoutId)
|
|
1537
|
+
}
|
|
1538
|
+
this.animationTimeouts = []
|
|
1539
|
+
this.playingAnimation = false
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
public getStats(): EngineStats {
|
|
1543
|
+
return { ...this.stats }
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
public runRenderLoop(callback?: () => void) {
|
|
1547
|
+
this.renderLoopCallback = callback || null
|
|
1548
|
+
|
|
1549
|
+
const loop = () => {
|
|
1550
|
+
this.render()
|
|
1551
|
+
|
|
1552
|
+
if (this.renderLoopCallback) {
|
|
1553
|
+
this.renderLoopCallback()
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
this.animationFrameId = requestAnimationFrame(loop)
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
this.animationFrameId = requestAnimationFrame(loop)
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
public stopRenderLoop() {
|
|
1563
|
+
if (this.animationFrameId !== null) {
|
|
1564
|
+
cancelAnimationFrame(this.animationFrameId)
|
|
1565
|
+
this.animationFrameId = null
|
|
1566
|
+
}
|
|
1567
|
+
this.renderLoopCallback = null
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
public dispose() {
|
|
1571
|
+
this.stopRenderLoop()
|
|
1572
|
+
this.stopAnimation()
|
|
1573
|
+
if (this.camera) this.camera.detachControl()
|
|
1574
|
+
if (this.resizeObserver) {
|
|
1575
|
+
this.resizeObserver.disconnect()
|
|
1576
|
+
this.resizeObserver = null
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Step 6: Load PMX model file
|
|
1581
|
+
public async loadModel(path: string) {
|
|
1582
|
+
const pathParts = path.split("/")
|
|
1583
|
+
pathParts.pop()
|
|
1584
|
+
const dir = pathParts.join("/") + "/"
|
|
1585
|
+
this.modelDir = dir
|
|
1586
|
+
|
|
1587
|
+
const model = await PmxLoader.load(path)
|
|
1588
|
+
// console.log({
|
|
1589
|
+
// vertices: Array.from(model.getVertices()),
|
|
1590
|
+
// indices: Array.from(model.getIndices()),
|
|
1591
|
+
// materials: model.getMaterials(),
|
|
1592
|
+
// textures: model.getTextures(),
|
|
1593
|
+
// bones: model.getSkeleton().bones,
|
|
1594
|
+
// skinning: { joints: Array.from(model.getSkinning().joints), weights: Array.from(model.getSkinning().weights) },
|
|
1595
|
+
// })
|
|
1596
|
+
this.physics = new Physics(model.getRigidbodies(), model.getJoints())
|
|
1597
|
+
await this.setupModelBuffers(model)
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
public rotateBones(bones: string[], rotations: Quat[], durationMs?: number) {
|
|
1601
|
+
this.currentModel?.rotateBones(bones, rotations, durationMs)
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Step 7: Create vertex, index, and joint buffers
|
|
1605
|
+
private async setupModelBuffers(model: Model) {
|
|
1606
|
+
this.currentModel = model
|
|
1607
|
+
const vertices = model.getVertices()
|
|
1608
|
+
const skinning = model.getSkinning()
|
|
1609
|
+
const skeleton = model.getSkeleton()
|
|
1610
|
+
|
|
1611
|
+
this.vertexBuffer = this.device.createBuffer({
|
|
1612
|
+
label: "model vertex buffer",
|
|
1613
|
+
size: vertices.byteLength,
|
|
1614
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
1615
|
+
})
|
|
1616
|
+
this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
|
|
1617
|
+
|
|
1618
|
+
this.jointsBuffer = this.device.createBuffer({
|
|
1619
|
+
label: "joints buffer",
|
|
1620
|
+
size: skinning.joints.byteLength,
|
|
1621
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
1622
|
+
})
|
|
1623
|
+
this.device.queue.writeBuffer(
|
|
1624
|
+
this.jointsBuffer,
|
|
1625
|
+
0,
|
|
1626
|
+
skinning.joints.buffer,
|
|
1627
|
+
skinning.joints.byteOffset,
|
|
1628
|
+
skinning.joints.byteLength
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
this.weightsBuffer = this.device.createBuffer({
|
|
1632
|
+
label: "weights buffer",
|
|
1633
|
+
size: skinning.weights.byteLength,
|
|
1634
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
1635
|
+
})
|
|
1636
|
+
this.device.queue.writeBuffer(
|
|
1637
|
+
this.weightsBuffer,
|
|
1638
|
+
0,
|
|
1639
|
+
skinning.weights.buffer,
|
|
1640
|
+
skinning.weights.byteOffset,
|
|
1641
|
+
skinning.weights.byteLength
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
const boneCount = skeleton.bones.length
|
|
1645
|
+
const matrixSize = boneCount * 16 * 4
|
|
1646
|
+
|
|
1647
|
+
this.skinMatrixBuffer = this.device.createBuffer({
|
|
1648
|
+
label: "skin matrices",
|
|
1649
|
+
size: Math.max(256, matrixSize),
|
|
1650
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
|
|
1651
|
+
})
|
|
1652
|
+
|
|
1653
|
+
this.worldMatrixBuffer = this.device.createBuffer({
|
|
1654
|
+
label: "world matrices",
|
|
1655
|
+
size: Math.max(256, matrixSize),
|
|
1656
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
1657
|
+
})
|
|
1658
|
+
|
|
1659
|
+
this.inverseBindMatrixBuffer = this.device.createBuffer({
|
|
1660
|
+
label: "inverse bind matrices",
|
|
1661
|
+
size: Math.max(256, matrixSize),
|
|
1662
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
1663
|
+
})
|
|
1664
|
+
|
|
1665
|
+
const invBindMatrices = skeleton.inverseBindMatrices
|
|
1666
|
+
this.device.queue.writeBuffer(
|
|
1667
|
+
this.inverseBindMatrixBuffer,
|
|
1668
|
+
0,
|
|
1669
|
+
invBindMatrices.buffer,
|
|
1670
|
+
invBindMatrices.byteOffset,
|
|
1671
|
+
invBindMatrices.byteLength
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
this.boneCountBuffer = this.device.createBuffer({
|
|
1675
|
+
label: "bone count uniform",
|
|
1676
|
+
size: 32, // Minimum uniform buffer size is 32 bytes
|
|
1677
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1678
|
+
})
|
|
1679
|
+
const boneCountData = new Uint32Array(8) // 32 bytes total
|
|
1680
|
+
boneCountData[0] = boneCount
|
|
1681
|
+
this.device.queue.writeBuffer(this.boneCountBuffer, 0, boneCountData)
|
|
1682
|
+
|
|
1683
|
+
this.createSkinMatrixComputePipeline()
|
|
1684
|
+
|
|
1685
|
+
// Create compute bind group once (reused every frame)
|
|
1686
|
+
this.skinMatrixComputeBindGroup = this.device.createBindGroup({
|
|
1687
|
+
layout: this.skinMatrixComputePipeline!.getBindGroupLayout(0),
|
|
1688
|
+
entries: [
|
|
1689
|
+
{ binding: 0, resource: { buffer: this.boneCountBuffer } },
|
|
1690
|
+
{ binding: 1, resource: { buffer: this.worldMatrixBuffer } },
|
|
1691
|
+
{ binding: 2, resource: { buffer: this.inverseBindMatrixBuffer } },
|
|
1692
|
+
{ binding: 3, resource: { buffer: this.skinMatrixBuffer } },
|
|
1693
|
+
],
|
|
1694
|
+
})
|
|
1695
|
+
|
|
1696
|
+
const indices = model.getIndices()
|
|
1697
|
+
if (indices) {
|
|
1698
|
+
this.indexBuffer = this.device.createBuffer({
|
|
1699
|
+
label: "model index buffer",
|
|
1700
|
+
size: indices.byteLength,
|
|
1701
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
1702
|
+
})
|
|
1703
|
+
this.device.queue.writeBuffer(this.indexBuffer, 0, indices)
|
|
1704
|
+
} else {
|
|
1705
|
+
throw new Error("Model has no index buffer")
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
await this.setupMaterials(model)
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
private async setupMaterials(model: Model) {
|
|
1712
|
+
const materials = model.getMaterials()
|
|
1713
|
+
if (materials.length === 0) {
|
|
1714
|
+
throw new Error("Model has no materials")
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const textures = model.getTextures()
|
|
1718
|
+
|
|
1719
|
+
const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
|
|
1720
|
+
if (texIndex < 0 || texIndex >= textures.length) {
|
|
1721
|
+
return null
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const path = this.modelDir + textures[texIndex].path
|
|
1725
|
+
const texture = await this.createTextureFromPath(path)
|
|
1726
|
+
return texture
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const loadToonTexture = async (toonTextureIndex: number): Promise<GPUTexture> => {
|
|
1730
|
+
const texture = await loadTextureByIndex(toonTextureIndex)
|
|
1731
|
+
if (texture) return texture
|
|
1732
|
+
|
|
1733
|
+
// Default toon texture fallback - cache it
|
|
1734
|
+
const defaultToonPath = "__default_toon__"
|
|
1735
|
+
const cached = this.textureCache.get(defaultToonPath)
|
|
1736
|
+
if (cached) return cached
|
|
1737
|
+
|
|
1738
|
+
const defaultToonData = new Uint8Array(256 * 2 * 4)
|
|
1739
|
+
for (let i = 0; i < 256; i++) {
|
|
1740
|
+
const factor = i / 255.0
|
|
1741
|
+
const gray = Math.floor(128 + factor * 127)
|
|
1742
|
+
defaultToonData[i * 4] = gray
|
|
1743
|
+
defaultToonData[i * 4 + 1] = gray
|
|
1744
|
+
defaultToonData[i * 4 + 2] = gray
|
|
1745
|
+
defaultToonData[i * 4 + 3] = 255
|
|
1746
|
+
defaultToonData[(256 + i) * 4] = gray
|
|
1747
|
+
defaultToonData[(256 + i) * 4 + 1] = gray
|
|
1748
|
+
defaultToonData[(256 + i) * 4 + 2] = gray
|
|
1749
|
+
defaultToonData[(256 + i) * 4 + 3] = 255
|
|
1750
|
+
}
|
|
1751
|
+
const defaultToonTexture = this.device.createTexture({
|
|
1752
|
+
label: "default toon texture",
|
|
1753
|
+
size: [256, 2],
|
|
1754
|
+
format: "rgba8unorm",
|
|
1755
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
1756
|
+
})
|
|
1757
|
+
this.device.queue.writeTexture(
|
|
1758
|
+
{ texture: defaultToonTexture },
|
|
1759
|
+
defaultToonData,
|
|
1760
|
+
{ bytesPerRow: 256 * 4 },
|
|
1761
|
+
[256, 2]
|
|
1762
|
+
)
|
|
1763
|
+
this.textureCache.set(defaultToonPath, defaultToonTexture)
|
|
1764
|
+
return defaultToonTexture
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
this.opaqueDraws = []
|
|
1768
|
+
this.eyeDraws = []
|
|
1769
|
+
this.hairDrawsOverEyes = []
|
|
1770
|
+
this.hairDrawsOverNonEyes = []
|
|
1771
|
+
this.transparentDraws = []
|
|
1772
|
+
this.opaqueOutlineDraws = []
|
|
1773
|
+
this.eyeOutlineDraws = []
|
|
1774
|
+
this.hairOutlineDraws = []
|
|
1775
|
+
this.transparentOutlineDraws = []
|
|
1776
|
+
let currentIndexOffset = 0
|
|
1777
|
+
|
|
1778
|
+
for (const mat of materials) {
|
|
1779
|
+
const indexCount = mat.vertexCount
|
|
1780
|
+
if (indexCount === 0) continue
|
|
1781
|
+
|
|
1782
|
+
const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
|
|
1783
|
+
if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
|
|
1784
|
+
|
|
1785
|
+
const toonTexture = await loadToonTexture(mat.toonTextureIndex)
|
|
1786
|
+
|
|
1787
|
+
const materialAlpha = mat.diffuse[3]
|
|
1788
|
+
const EPSILON = 0.001
|
|
1789
|
+
const isTransparent = materialAlpha < 1.0 - EPSILON
|
|
1790
|
+
|
|
1791
|
+
// Create material uniform data
|
|
1792
|
+
const materialUniformData = new Float32Array(8)
|
|
1793
|
+
materialUniformData[0] = materialAlpha
|
|
1794
|
+
materialUniformData[1] = 1.0 // alphaMultiplier: 1.0 for non-hair materials
|
|
1795
|
+
materialUniformData[2] = this.rimLightIntensity
|
|
1796
|
+
materialUniformData[3] = 0.0 // _padding1
|
|
1797
|
+
materialUniformData[4] = 1.0 // rimColor.r
|
|
1798
|
+
materialUniformData[5] = 1.0 // rimColor.g
|
|
1799
|
+
materialUniformData[6] = 1.0 // rimColor.b
|
|
1800
|
+
materialUniformData[7] = 0.0 // isOverEyes
|
|
1801
|
+
|
|
1802
|
+
const materialUniformBuffer = this.device.createBuffer({
|
|
1803
|
+
label: `material uniform: ${mat.name}`,
|
|
1804
|
+
size: materialUniformData.byteLength,
|
|
1805
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1806
|
+
})
|
|
1807
|
+
this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
|
|
1808
|
+
|
|
1809
|
+
// Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
|
|
1810
|
+
const bindGroup = this.device.createBindGroup({
|
|
1811
|
+
label: `material bind group: ${mat.name}`,
|
|
1812
|
+
layout: this.mainBindGroupLayout,
|
|
1813
|
+
entries: [
|
|
1814
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
1815
|
+
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
1816
|
+
{ binding: 2, resource: diffuseTexture.createView() },
|
|
1817
|
+
{ binding: 3, resource: this.materialSampler },
|
|
1818
|
+
{ binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
|
|
1819
|
+
{ binding: 5, resource: toonTexture.createView() },
|
|
1820
|
+
{ binding: 6, resource: this.materialSampler },
|
|
1821
|
+
{ binding: 7, resource: { buffer: materialUniformBuffer } },
|
|
1822
|
+
],
|
|
1823
|
+
})
|
|
1824
|
+
|
|
1825
|
+
if (mat.isEye) {
|
|
1826
|
+
this.eyeDraws.push({
|
|
1827
|
+
count: indexCount,
|
|
1828
|
+
firstIndex: currentIndexOffset,
|
|
1829
|
+
bindGroup,
|
|
1830
|
+
isTransparent,
|
|
1831
|
+
})
|
|
1832
|
+
} else if (mat.isHair) {
|
|
1833
|
+
// Hair materials: create separate bind groups for over-eyes vs over-non-eyes
|
|
1834
|
+
const createHairBindGroup = (isOverEyes: boolean) => {
|
|
1835
|
+
const uniformData = new Float32Array(8)
|
|
1836
|
+
uniformData[0] = materialAlpha
|
|
1837
|
+
uniformData[1] = 1.0 // alphaMultiplier (shader adjusts based on isOverEyes)
|
|
1838
|
+
uniformData[2] = this.rimLightIntensity
|
|
1839
|
+
uniformData[3] = 0.0 // _padding1
|
|
1840
|
+
uniformData[4] = 1.0 // rimColor.rgb
|
|
1841
|
+
uniformData[5] = 1.0
|
|
1842
|
+
uniformData[6] = 1.0
|
|
1843
|
+
uniformData[7] = isOverEyes ? 1.0 : 0.0 // isOverEyes
|
|
1844
|
+
|
|
1845
|
+
const buffer = this.device.createBuffer({
|
|
1846
|
+
label: `material uniform (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
|
|
1847
|
+
size: uniformData.byteLength,
|
|
1848
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1849
|
+
})
|
|
1850
|
+
this.device.queue.writeBuffer(buffer, 0, uniformData)
|
|
1851
|
+
|
|
1852
|
+
return this.device.createBindGroup({
|
|
1853
|
+
label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
|
|
1854
|
+
layout: this.mainBindGroupLayout,
|
|
1855
|
+
entries: [
|
|
1856
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
1857
|
+
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
1858
|
+
{ binding: 2, resource: diffuseTexture.createView() },
|
|
1859
|
+
{ binding: 3, resource: this.materialSampler },
|
|
1860
|
+
{ binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
|
|
1861
|
+
{ binding: 5, resource: toonTexture.createView() },
|
|
1862
|
+
{ binding: 6, resource: this.materialSampler },
|
|
1863
|
+
{ binding: 7, resource: { buffer: buffer } },
|
|
1864
|
+
],
|
|
1865
|
+
})
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
const bindGroupOverEyes = createHairBindGroup(true)
|
|
1869
|
+
const bindGroupOverNonEyes = createHairBindGroup(false)
|
|
1870
|
+
|
|
1871
|
+
this.hairDrawsOverEyes.push({
|
|
1872
|
+
count: indexCount,
|
|
1873
|
+
firstIndex: currentIndexOffset,
|
|
1874
|
+
bindGroup: bindGroupOverEyes,
|
|
1875
|
+
isTransparent,
|
|
1876
|
+
})
|
|
1877
|
+
|
|
1878
|
+
this.hairDrawsOverNonEyes.push({
|
|
1879
|
+
count: indexCount,
|
|
1880
|
+
firstIndex: currentIndexOffset,
|
|
1881
|
+
bindGroup: bindGroupOverNonEyes,
|
|
1882
|
+
isTransparent,
|
|
1883
|
+
})
|
|
1884
|
+
} else if (isTransparent) {
|
|
1885
|
+
this.transparentDraws.push({
|
|
1886
|
+
count: indexCount,
|
|
1887
|
+
firstIndex: currentIndexOffset,
|
|
1888
|
+
bindGroup,
|
|
1889
|
+
isTransparent,
|
|
1890
|
+
})
|
|
1891
|
+
} else {
|
|
1892
|
+
this.opaqueDraws.push({
|
|
1893
|
+
count: indexCount,
|
|
1894
|
+
firstIndex: currentIndexOffset,
|
|
1895
|
+
bindGroup,
|
|
1896
|
+
isTransparent,
|
|
1897
|
+
})
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// Edge flag is at bit 4 (0x10) in PMX format
|
|
1901
|
+
if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
|
|
1902
|
+
const materialUniformData = new Float32Array(8)
|
|
1903
|
+
materialUniformData[0] = mat.edgeColor[0] // edgeColor.r
|
|
1904
|
+
materialUniformData[1] = mat.edgeColor[1] // edgeColor.g
|
|
1905
|
+
materialUniformData[2] = mat.edgeColor[2] // edgeColor.b
|
|
1906
|
+
materialUniformData[3] = mat.edgeColor[3] // edgeColor.a
|
|
1907
|
+
materialUniformData[4] = mat.edgeSize
|
|
1908
|
+
materialUniformData[5] = 0.0 // isOverEyes
|
|
1909
|
+
materialUniformData[6] = 0.0
|
|
1910
|
+
materialUniformData[7] = 0.0
|
|
1911
|
+
|
|
1912
|
+
const materialUniformBuffer = this.device.createBuffer({
|
|
1913
|
+
label: `outline material uniform: ${mat.name}`,
|
|
1914
|
+
size: materialUniformData.byteLength,
|
|
1915
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1916
|
+
})
|
|
1917
|
+
this.device.queue.writeBuffer(materialUniformBuffer, 0, materialUniformData)
|
|
1918
|
+
|
|
1919
|
+
const outlineBindGroup = this.device.createBindGroup({
|
|
1920
|
+
label: `outline bind group: ${mat.name}`,
|
|
1921
|
+
layout: this.outlineBindGroupLayout,
|
|
1922
|
+
entries: [
|
|
1923
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
1924
|
+
{ binding: 1, resource: { buffer: materialUniformBuffer } },
|
|
1925
|
+
{ binding: 2, resource: { buffer: this.skinMatrixBuffer! } },
|
|
1926
|
+
],
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1929
|
+
if (mat.isEye) {
|
|
1930
|
+
this.eyeOutlineDraws.push({
|
|
1931
|
+
count: indexCount,
|
|
1932
|
+
firstIndex: currentIndexOffset,
|
|
1933
|
+
bindGroup: outlineBindGroup,
|
|
1934
|
+
isTransparent,
|
|
1935
|
+
})
|
|
1936
|
+
} else if (mat.isHair) {
|
|
1937
|
+
this.hairOutlineDraws.push({
|
|
1938
|
+
count: indexCount,
|
|
1939
|
+
firstIndex: currentIndexOffset,
|
|
1940
|
+
bindGroup: outlineBindGroup,
|
|
1941
|
+
isTransparent,
|
|
1942
|
+
})
|
|
1943
|
+
} else if (isTransparent) {
|
|
1944
|
+
this.transparentOutlineDraws.push({
|
|
1945
|
+
count: indexCount,
|
|
1946
|
+
firstIndex: currentIndexOffset,
|
|
1947
|
+
bindGroup: outlineBindGroup,
|
|
1948
|
+
isTransparent,
|
|
1949
|
+
})
|
|
1950
|
+
} else {
|
|
1951
|
+
this.opaqueOutlineDraws.push({
|
|
1952
|
+
count: indexCount,
|
|
1953
|
+
firstIndex: currentIndexOffset,
|
|
1954
|
+
bindGroup: outlineBindGroup,
|
|
1955
|
+
isTransparent,
|
|
1956
|
+
})
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
currentIndexOffset += indexCount
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
this.gpuMemoryMB = this.calculateGpuMemory()
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
|
|
1967
|
+
const cached = this.textureCache.get(path)
|
|
1968
|
+
if (cached) {
|
|
1969
|
+
return cached
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
try {
|
|
1973
|
+
const response = await fetch(path)
|
|
1974
|
+
if (!response.ok) {
|
|
1975
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
1976
|
+
}
|
|
1977
|
+
const imageBitmap = await createImageBitmap(await response.blob(), {
|
|
1978
|
+
premultiplyAlpha: "none",
|
|
1979
|
+
colorSpaceConversion: "none",
|
|
1980
|
+
})
|
|
1981
|
+
|
|
1982
|
+
const texture = this.device.createTexture({
|
|
1983
|
+
label: `texture: ${path}`,
|
|
1984
|
+
size: [imageBitmap.width, imageBitmap.height],
|
|
1985
|
+
format: "rgba8unorm",
|
|
1986
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1987
|
+
})
|
|
1988
|
+
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
|
|
1989
|
+
imageBitmap.width,
|
|
1990
|
+
imageBitmap.height,
|
|
1991
|
+
])
|
|
1992
|
+
|
|
1993
|
+
this.textureCache.set(path, texture)
|
|
1994
|
+
return texture
|
|
1995
|
+
} catch {
|
|
1996
|
+
return null
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
|
|
2001
|
+
public render() {
|
|
2002
|
+
if (this.multisampleTexture && this.camera && this.device) {
|
|
2003
|
+
const currentTime = performance.now()
|
|
2004
|
+
const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
|
|
2005
|
+
this.lastFrameTime = currentTime
|
|
2006
|
+
|
|
2007
|
+
this.updateCameraUniforms()
|
|
2008
|
+
this.updateRenderTarget()
|
|
2009
|
+
|
|
2010
|
+
// Use single encoder for both compute and render (reduces sync points)
|
|
2011
|
+
const encoder = this.device.createCommandEncoder()
|
|
2012
|
+
|
|
2013
|
+
this.updateModelPose(deltaTime, encoder)
|
|
2014
|
+
|
|
2015
|
+
// Hide model if animation is loaded but not playing yet (prevents A-pose flash)
|
|
2016
|
+
// Still update physics and poses, just don't render visually
|
|
2017
|
+
if (this.hasAnimation && !this.playingAnimation) {
|
|
2018
|
+
// Submit encoder to ensure matrices are uploaded and physics initializes
|
|
2019
|
+
this.device.queue.submit([encoder.finish()])
|
|
2020
|
+
return
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
const pass = encoder.beginRenderPass(this.renderPassDescriptor)
|
|
2024
|
+
|
|
2025
|
+
this.drawCallCount = 0
|
|
2026
|
+
|
|
2027
|
+
if (this.currentModel) {
|
|
2028
|
+
pass.setVertexBuffer(0, this.vertexBuffer)
|
|
2029
|
+
pass.setVertexBuffer(1, this.jointsBuffer)
|
|
2030
|
+
pass.setVertexBuffer(2, this.weightsBuffer)
|
|
2031
|
+
pass.setIndexBuffer(this.indexBuffer!, "uint32")
|
|
2032
|
+
|
|
2033
|
+
// Pass 1: Opaque
|
|
2034
|
+
pass.setPipeline(this.modelPipeline)
|
|
2035
|
+
for (const draw of this.opaqueDraws) {
|
|
2036
|
+
if (draw.count > 0) {
|
|
2037
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2038
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2039
|
+
this.drawCallCount++
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// Pass 2: Eyes (writes stencil value for hair to test against)
|
|
2044
|
+
pass.setPipeline(this.eyePipeline)
|
|
2045
|
+
pass.setStencilReference(this.STENCIL_EYE_VALUE)
|
|
2046
|
+
for (const draw of this.eyeDraws) {
|
|
2047
|
+
if (draw.count > 0) {
|
|
2048
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2049
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2050
|
+
this.drawCallCount++
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// Pass 3: Hair rendering (depth pre-pass + shading + outlines)
|
|
2055
|
+
this.drawOutlines(pass, false)
|
|
2056
|
+
|
|
2057
|
+
// 3a: Hair depth pre-pass (reduces overdraw via early depth rejection)
|
|
2058
|
+
if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
|
|
2059
|
+
pass.setPipeline(this.hairDepthPipeline)
|
|
2060
|
+
for (const draw of this.hairDrawsOverEyes) {
|
|
2061
|
+
if (draw.count > 0) {
|
|
2062
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2063
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
for (const draw of this.hairDrawsOverNonEyes) {
|
|
2067
|
+
if (draw.count > 0) {
|
|
2068
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2069
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// 3b: Hair shading (split by stencil for transparency over eyes)
|
|
2075
|
+
if (this.hairDrawsOverEyes.length > 0) {
|
|
2076
|
+
pass.setPipeline(this.hairPipelineOverEyes)
|
|
2077
|
+
pass.setStencilReference(this.STENCIL_EYE_VALUE)
|
|
2078
|
+
for (const draw of this.hairDrawsOverEyes) {
|
|
2079
|
+
if (draw.count > 0) {
|
|
2080
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2081
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2082
|
+
this.drawCallCount++
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
if (this.hairDrawsOverNonEyes.length > 0) {
|
|
2088
|
+
pass.setPipeline(this.hairPipelineOverNonEyes)
|
|
2089
|
+
pass.setStencilReference(this.STENCIL_EYE_VALUE)
|
|
2090
|
+
for (const draw of this.hairDrawsOverNonEyes) {
|
|
2091
|
+
if (draw.count > 0) {
|
|
2092
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2093
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2094
|
+
this.drawCallCount++
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// 3c: Hair outlines
|
|
2100
|
+
if (this.hairOutlineDraws.length > 0) {
|
|
2101
|
+
pass.setPipeline(this.hairOutlinePipeline)
|
|
2102
|
+
for (const draw of this.hairOutlineDraws) {
|
|
2103
|
+
if (draw.count > 0) {
|
|
2104
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2105
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// Pass 4: Transparent
|
|
2111
|
+
pass.setPipeline(this.modelPipeline)
|
|
2112
|
+
for (const draw of this.transparentDraws) {
|
|
2113
|
+
if (draw.count > 0) {
|
|
2114
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2115
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2116
|
+
this.drawCallCount++
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
this.drawOutlines(pass, true)
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
pass.end()
|
|
2124
|
+
this.device.queue.submit([encoder.finish()])
|
|
2125
|
+
|
|
2126
|
+
this.applyBloom()
|
|
2127
|
+
|
|
2128
|
+
this.updateStats(performance.now() - currentTime)
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
private applyBloom() {
|
|
2133
|
+
if (!this.sceneRenderTexture || !this.bloomExtractTexture) {
|
|
2134
|
+
return
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// Update bloom parameters
|
|
2138
|
+
const thresholdData = new Float32Array(8)
|
|
2139
|
+
thresholdData[0] = this.bloomThreshold
|
|
2140
|
+
this.device.queue.writeBuffer(this.bloomThresholdBuffer, 0, thresholdData)
|
|
2141
|
+
|
|
2142
|
+
const intensityData = new Float32Array(8)
|
|
2143
|
+
intensityData[0] = this.bloomIntensity
|
|
2144
|
+
this.device.queue.writeBuffer(this.bloomIntensityBuffer, 0, intensityData)
|
|
2145
|
+
|
|
2146
|
+
const encoder = this.device.createCommandEncoder()
|
|
2147
|
+
|
|
2148
|
+
// Extract bright areas
|
|
2149
|
+
const extractPass = encoder.beginRenderPass({
|
|
2150
|
+
label: "bloom extract",
|
|
2151
|
+
colorAttachments: [
|
|
2152
|
+
{
|
|
2153
|
+
view: this.bloomExtractTexture.createView(),
|
|
2154
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
2155
|
+
loadOp: "clear",
|
|
2156
|
+
storeOp: "store",
|
|
2157
|
+
},
|
|
2158
|
+
],
|
|
2159
|
+
})
|
|
2160
|
+
|
|
2161
|
+
extractPass.setPipeline(this.bloomExtractPipeline)
|
|
2162
|
+
extractPass.setBindGroup(0, this.bloomExtractBindGroup!)
|
|
2163
|
+
extractPass.draw(6, 1, 0, 0)
|
|
2164
|
+
extractPass.end()
|
|
2165
|
+
|
|
2166
|
+
// Horizontal blur
|
|
2167
|
+
const hBlurData = new Float32Array(4)
|
|
2168
|
+
hBlurData[0] = 1.0
|
|
2169
|
+
hBlurData[1] = 0.0
|
|
2170
|
+
this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, hBlurData)
|
|
2171
|
+
const blurHPass = encoder.beginRenderPass({
|
|
2172
|
+
label: "bloom blur horizontal",
|
|
2173
|
+
colorAttachments: [
|
|
2174
|
+
{
|
|
2175
|
+
view: this.bloomBlurTexture1.createView(),
|
|
2176
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
2177
|
+
loadOp: "clear",
|
|
2178
|
+
storeOp: "store",
|
|
2179
|
+
},
|
|
2180
|
+
],
|
|
2181
|
+
})
|
|
2182
|
+
|
|
2183
|
+
blurHPass.setPipeline(this.bloomBlurPipeline)
|
|
2184
|
+
blurHPass.setBindGroup(0, this.bloomBlurHBindGroup!)
|
|
2185
|
+
blurHPass.draw(6, 1, 0, 0)
|
|
2186
|
+
blurHPass.end()
|
|
2187
|
+
|
|
2188
|
+
// Vertical blur
|
|
2189
|
+
const vBlurData = new Float32Array(4)
|
|
2190
|
+
vBlurData[0] = 0.0
|
|
2191
|
+
vBlurData[1] = 1.0
|
|
2192
|
+
this.device.queue.writeBuffer(this.blurDirectionBuffer, 0, vBlurData)
|
|
2193
|
+
const blurVPass = encoder.beginRenderPass({
|
|
2194
|
+
label: "bloom blur vertical",
|
|
2195
|
+
colorAttachments: [
|
|
2196
|
+
{
|
|
2197
|
+
view: this.bloomBlurTexture2.createView(),
|
|
2198
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
2199
|
+
loadOp: "clear",
|
|
2200
|
+
storeOp: "store",
|
|
2201
|
+
},
|
|
2202
|
+
],
|
|
2203
|
+
})
|
|
2204
|
+
|
|
2205
|
+
blurVPass.setPipeline(this.bloomBlurPipeline)
|
|
2206
|
+
blurVPass.setBindGroup(0, this.bloomBlurVBindGroup!)
|
|
2207
|
+
blurVPass.draw(6, 1, 0, 0)
|
|
2208
|
+
blurVPass.end()
|
|
2209
|
+
|
|
2210
|
+
// Compose to canvas
|
|
2211
|
+
const composePass = encoder.beginRenderPass({
|
|
2212
|
+
label: "bloom compose",
|
|
2213
|
+
colorAttachments: [
|
|
2214
|
+
{
|
|
2215
|
+
view: this.context.getCurrentTexture().createView(),
|
|
2216
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
2217
|
+
loadOp: "clear",
|
|
2218
|
+
storeOp: "store",
|
|
2219
|
+
},
|
|
2220
|
+
],
|
|
2221
|
+
})
|
|
2222
|
+
|
|
2223
|
+
composePass.setPipeline(this.bloomComposePipeline)
|
|
2224
|
+
composePass.setBindGroup(0, this.bloomComposeBindGroup!)
|
|
2225
|
+
composePass.draw(6, 1, 0, 0)
|
|
2226
|
+
composePass.end()
|
|
2227
|
+
|
|
2228
|
+
this.device.queue.submit([encoder.finish()])
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
private updateCameraUniforms() {
|
|
2232
|
+
const viewMatrix = this.camera.getViewMatrix()
|
|
2233
|
+
const projectionMatrix = this.camera.getProjectionMatrix()
|
|
2234
|
+
const cameraPos = this.camera.getPosition()
|
|
2235
|
+
this.cameraMatrixData.set(viewMatrix.values, 0)
|
|
2236
|
+
this.cameraMatrixData.set(projectionMatrix.values, 16)
|
|
2237
|
+
this.cameraMatrixData[32] = cameraPos.x
|
|
2238
|
+
this.cameraMatrixData[33] = cameraPos.y
|
|
2239
|
+
this.cameraMatrixData[34] = cameraPos.z
|
|
2240
|
+
this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
private updateRenderTarget() {
|
|
2244
|
+
const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
2245
|
+
if (this.sampleCount > 1) {
|
|
2246
|
+
colorAttachment.resolveTarget = this.sceneRenderTextureView
|
|
2247
|
+
} else {
|
|
2248
|
+
colorAttachment.view = this.sceneRenderTextureView
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
private updateModelPose(deltaTime: number, encoder: GPUCommandEncoder) {
|
|
2253
|
+
this.currentModel!.evaluatePose()
|
|
2254
|
+
const worldMats = this.currentModel!.getBoneWorldMatrices()
|
|
2255
|
+
|
|
2256
|
+
if (this.physics) {
|
|
2257
|
+
this.physics.step(deltaTime, worldMats, this.currentModel!.getBoneInverseBindMatrices())
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
this.device.queue.writeBuffer(
|
|
2261
|
+
this.worldMatrixBuffer!,
|
|
2262
|
+
0,
|
|
2263
|
+
worldMats.buffer,
|
|
2264
|
+
worldMats.byteOffset,
|
|
2265
|
+
worldMats.byteLength
|
|
2266
|
+
)
|
|
2267
|
+
this.computeSkinMatrices(encoder)
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
private computeSkinMatrices(encoder: GPUCommandEncoder) {
|
|
2271
|
+
const boneCount = this.currentModel!.getSkeleton().bones.length
|
|
2272
|
+
const workgroupCount = Math.ceil(boneCount / this.COMPUTE_WORKGROUP_SIZE)
|
|
2273
|
+
|
|
2274
|
+
const pass = encoder.beginComputePass()
|
|
2275
|
+
pass.setPipeline(this.skinMatrixComputePipeline!)
|
|
2276
|
+
pass.setBindGroup(0, this.skinMatrixComputeBindGroup!)
|
|
2277
|
+
pass.dispatchWorkgroups(workgroupCount)
|
|
2278
|
+
pass.end()
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean) {
|
|
2282
|
+
pass.setPipeline(this.outlinePipeline)
|
|
2283
|
+
if (transparent) {
|
|
2284
|
+
for (const draw of this.transparentOutlineDraws) {
|
|
2285
|
+
if (draw.count > 0) {
|
|
2286
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2287
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
} else {
|
|
2291
|
+
for (const draw of this.opaqueOutlineDraws) {
|
|
2292
|
+
if (draw.count > 0) {
|
|
2293
|
+
pass.setBindGroup(0, draw.bindGroup)
|
|
2294
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
private updateStats(frameTime: number) {
|
|
2301
|
+
const maxSamples = 60
|
|
2302
|
+
this.frameTimeSamples.push(frameTime)
|
|
2303
|
+
this.frameTimeSum += frameTime
|
|
2304
|
+
if (this.frameTimeSamples.length > maxSamples) {
|
|
2305
|
+
const removed = this.frameTimeSamples.shift()!
|
|
2306
|
+
this.frameTimeSum -= removed
|
|
2307
|
+
}
|
|
2308
|
+
const avgFrameTime = this.frameTimeSum / this.frameTimeSamples.length
|
|
2309
|
+
this.stats.frameTime = Math.round(avgFrameTime * 100) / 100
|
|
2310
|
+
|
|
2311
|
+
const now = performance.now()
|
|
2312
|
+
this.framesSinceLastUpdate++
|
|
2313
|
+
const elapsed = now - this.lastFpsUpdate
|
|
2314
|
+
|
|
2315
|
+
if (elapsed >= 1000) {
|
|
2316
|
+
this.stats.fps = Math.round((this.framesSinceLastUpdate / elapsed) * 1000)
|
|
2317
|
+
this.framesSinceLastUpdate = 0
|
|
2318
|
+
this.lastFpsUpdate = now
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
this.stats.gpuMemory = this.gpuMemoryMB
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
private calculateGpuMemory(): number {
|
|
2325
|
+
let textureMemoryBytes = 0
|
|
2326
|
+
for (const texture of this.textureCache.values()) {
|
|
2327
|
+
textureMemoryBytes += texture.width * texture.height * 4
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
let bufferMemoryBytes = 0
|
|
2331
|
+
if (this.vertexBuffer) {
|
|
2332
|
+
const vertices = this.currentModel?.getVertices()
|
|
2333
|
+
if (vertices) bufferMemoryBytes += vertices.byteLength
|
|
2334
|
+
}
|
|
2335
|
+
if (this.indexBuffer) {
|
|
2336
|
+
const indices = this.currentModel?.getIndices()
|
|
2337
|
+
if (indices) bufferMemoryBytes += indices.byteLength
|
|
2338
|
+
}
|
|
2339
|
+
if (this.jointsBuffer) {
|
|
2340
|
+
const skinning = this.currentModel?.getSkinning()
|
|
2341
|
+
if (skinning) bufferMemoryBytes += skinning.joints.byteLength
|
|
2342
|
+
}
|
|
2343
|
+
if (this.weightsBuffer) {
|
|
2344
|
+
const skinning = this.currentModel?.getSkinning()
|
|
2345
|
+
if (skinning) bufferMemoryBytes += skinning.weights.byteLength
|
|
2346
|
+
}
|
|
2347
|
+
if (this.skinMatrixBuffer) {
|
|
2348
|
+
const skeleton = this.currentModel?.getSkeleton()
|
|
2349
|
+
if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
|
|
2350
|
+
}
|
|
2351
|
+
if (this.worldMatrixBuffer) {
|
|
2352
|
+
const skeleton = this.currentModel?.getSkeleton()
|
|
2353
|
+
if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
|
|
2354
|
+
}
|
|
2355
|
+
if (this.inverseBindMatrixBuffer) {
|
|
2356
|
+
const skeleton = this.currentModel?.getSkeleton()
|
|
2357
|
+
if (skeleton) bufferMemoryBytes += Math.max(256, skeleton.bones.length * 16 * 4)
|
|
2358
|
+
}
|
|
2359
|
+
bufferMemoryBytes += 40 * 4
|
|
2360
|
+
bufferMemoryBytes += 64 * 4
|
|
2361
|
+
bufferMemoryBytes += 32
|
|
2362
|
+
bufferMemoryBytes += 32
|
|
2363
|
+
bufferMemoryBytes += 32
|
|
2364
|
+
bufferMemoryBytes += 32
|
|
2365
|
+
if (this.fullscreenQuadBuffer) {
|
|
2366
|
+
bufferMemoryBytes += 24 * 4
|
|
2367
|
+
}
|
|
2368
|
+
const totalMaterialDraws =
|
|
2369
|
+
this.opaqueDraws.length +
|
|
2370
|
+
this.eyeDraws.length +
|
|
2371
|
+
this.hairDrawsOverEyes.length +
|
|
2372
|
+
this.hairDrawsOverNonEyes.length +
|
|
2373
|
+
this.transparentDraws.length
|
|
2374
|
+
bufferMemoryBytes += totalMaterialDraws * 32
|
|
2375
|
+
|
|
2376
|
+
const totalOutlineDraws =
|
|
2377
|
+
this.opaqueOutlineDraws.length +
|
|
2378
|
+
this.eyeOutlineDraws.length +
|
|
2379
|
+
this.hairOutlineDraws.length +
|
|
2380
|
+
this.transparentOutlineDraws.length
|
|
2381
|
+
bufferMemoryBytes += totalOutlineDraws * 32
|
|
2382
|
+
|
|
2383
|
+
let renderTargetMemoryBytes = 0
|
|
2384
|
+
if (this.multisampleTexture) {
|
|
2385
|
+
const width = this.canvas.width
|
|
2386
|
+
const height = this.canvas.height
|
|
2387
|
+
renderTargetMemoryBytes += width * height * 4 * this.sampleCount
|
|
2388
|
+
renderTargetMemoryBytes += width * height * 4
|
|
2389
|
+
}
|
|
2390
|
+
if (this.sceneRenderTexture) {
|
|
2391
|
+
const width = this.canvas.width
|
|
2392
|
+
const height = this.canvas.height
|
|
2393
|
+
renderTargetMemoryBytes += width * height * 4
|
|
2394
|
+
}
|
|
2395
|
+
if (this.bloomExtractTexture) {
|
|
2396
|
+
const width = Math.floor(this.canvas.width / this.BLOOM_DOWNSCALE_FACTOR)
|
|
2397
|
+
const height = Math.floor(this.canvas.height / this.BLOOM_DOWNSCALE_FACTOR)
|
|
2398
|
+
renderTargetMemoryBytes += width * height * 4 * 3
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
const totalGPUMemoryBytes = textureMemoryBytes + bufferMemoryBytes + renderTargetMemoryBytes
|
|
2402
|
+
return Math.round((totalGPUMemoryBytes / 1024 / 1024) * 100) / 100
|
|
2403
|
+
}
|
|
2404
|
+
}
|