reze-engine 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +113 -20
  2. package/dist/asset-reader.d.ts +16 -0
  3. package/dist/asset-reader.d.ts.map +1 -0
  4. package/dist/asset-reader.js +74 -0
  5. package/dist/engine.d.ts +179 -36
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +1133 -321
  8. package/dist/folder-upload.d.ts +24 -0
  9. package/dist/folder-upload.d.ts.map +1 -0
  10. package/dist/folder-upload.js +50 -0
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/model.d.ts +6 -1
  15. package/dist/model.d.ts.map +1 -1
  16. package/dist/model.js +34 -2
  17. package/dist/pmx-loader.d.ts +3 -0
  18. package/dist/pmx-loader.d.ts.map +1 -1
  19. package/dist/pmx-loader.js +9 -2
  20. package/dist/shaders/body.d.ts +2 -0
  21. package/dist/shaders/body.d.ts.map +1 -0
  22. package/dist/shaders/body.js +209 -0
  23. package/dist/shaders/classify.d.ts +4 -0
  24. package/dist/shaders/classify.d.ts.map +1 -0
  25. package/dist/shaders/classify.js +12 -0
  26. package/dist/shaders/cloth_rough.d.ts +2 -0
  27. package/dist/shaders/cloth_rough.d.ts.map +1 -0
  28. package/dist/shaders/cloth_rough.js +172 -0
  29. package/dist/shaders/cloth_smooth.d.ts +2 -0
  30. package/dist/shaders/cloth_smooth.d.ts.map +1 -0
  31. package/dist/shaders/cloth_smooth.js +171 -0
  32. package/dist/shaders/default.d.ts +2 -0
  33. package/dist/shaders/default.d.ts.map +1 -0
  34. package/dist/shaders/default.js +168 -0
  35. package/dist/shaders/dfg_lut.d.ts +4 -0
  36. package/dist/shaders/dfg_lut.d.ts.map +1 -0
  37. package/dist/shaders/dfg_lut.js +125 -0
  38. package/dist/shaders/eye.d.ts +2 -0
  39. package/dist/shaders/eye.d.ts.map +1 -0
  40. package/dist/shaders/eye.js +142 -0
  41. package/dist/shaders/face.d.ts +2 -0
  42. package/dist/shaders/face.d.ts.map +1 -0
  43. package/dist/shaders/face.js +211 -0
  44. package/dist/shaders/hair.d.ts +2 -0
  45. package/dist/shaders/hair.d.ts.map +1 -0
  46. package/dist/shaders/hair.js +186 -0
  47. package/dist/shaders/ltc_mag_lut.d.ts +3 -0
  48. package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
  49. package/dist/shaders/ltc_mag_lut.js +1033 -0
  50. package/dist/shaders/metal.d.ts +2 -0
  51. package/dist/shaders/metal.d.ts.map +1 -0
  52. package/dist/shaders/metal.js +171 -0
  53. package/dist/shaders/nodes.d.ts +2 -0
  54. package/dist/shaders/nodes.d.ts.map +1 -0
  55. package/dist/shaders/nodes.js +423 -0
  56. package/dist/shaders/stockings.d.ts +2 -0
  57. package/dist/shaders/stockings.d.ts.map +1 -0
  58. package/dist/shaders/stockings.js +229 -0
  59. package/package.json +1 -1
  60. package/src/asset-reader.ts +79 -0
  61. package/src/engine.ts +1352 -383
  62. package/src/folder-upload.ts +59 -0
  63. package/src/index.ts +12 -2
  64. package/src/model.ts +34 -2
  65. package/src/pmx-loader.ts +11 -2
  66. package/src/shaders/body.ts +211 -0
  67. package/src/shaders/classify.ts +25 -0
  68. package/src/shaders/cloth_rough.ts +174 -0
  69. package/src/shaders/cloth_smooth.ts +173 -0
  70. package/src/shaders/default.ts +169 -0
  71. package/src/shaders/dfg_lut.ts +127 -0
  72. package/src/shaders/eye.ts +143 -0
  73. package/src/shaders/face.ts +213 -0
  74. package/src/shaders/hair.ts +188 -0
  75. package/src/shaders/ltc_mag_lut.ts +1035 -0
  76. package/src/shaders/metal.ts +173 -0
  77. package/src/shaders/nodes.ts +424 -0
  78. package/src/shaders/stockings.ts +231 -0
package/src/engine.ts CHANGED
@@ -3,33 +3,118 @@ import { Mat4, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
4
  import { PmxLoader } from "./pmx-loader"
5
5
  import { Physics, type PhysicsOptions } from "./physics"
6
+ import {
7
+ createFetchAssetReader,
8
+ createFileMapAssetReader,
9
+ deriveBasePathFromPmxPath,
10
+ fileListToMap,
11
+ findFirstPmxFileInList,
12
+ joinAssetPath,
13
+ normalizeAssetPath,
14
+ type AssetReader,
15
+ } from "./asset-reader"
16
+ import { DEFAULT_SHADER_WGSL } from "./shaders/default"
17
+ import { DFG_LUT_SIZE, DFG_LUT_WGSL } from "./shaders/dfg_lut"
18
+ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
19
+ import { FACE_SHADER_WGSL } from "./shaders/face"
20
+ import { HAIR_SHADER_WGSL } from "./shaders/hair"
21
+ import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/cloth_smooth"
22
+ import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/cloth_rough"
23
+ import { METAL_SHADER_WGSL } from "./shaders/metal"
24
+ import { BODY_SHADER_WGSL } from "./shaders/body"
25
+ import { EYE_SHADER_WGSL } from "./shaders/eye"
26
+ import { STOCKINGS_SHADER_WGSL } from "./shaders/stockings"
27
+ import { resolvePreset, type MaterialPreset, type MaterialPresetMap } from "./shaders/classify"
6
28
 
7
29
  export type RaycastCallback = (modelName: string, material: string | null, screenX: number, screenY: number) => void
8
30
 
31
+ /** Select a folder (webkitdirectory) and pass FileList or File[]; pmxFile picks which .pmx when several exist. */
32
+ export type LoadModelFromFilesOptions = {
33
+ files: FileList | File[]
34
+ pmxFile?: File
35
+ }
36
+
37
+ // Blender-style scene config. World = environment lighting (ambient);
38
+ // Sun = the single directional lamp; Camera = view framing.
39
+ export type WorldOptions = {
40
+ /** Linear scene-referred color of the World Background (Blender: World > Surface > Color). */
41
+ color?: Vec3
42
+ /** Multiplier on world color (Blender: World > Surface > Strength). */
43
+ strength?: number
44
+ }
45
+
46
+ export type SunOptions = {
47
+ /** Linear color of the sun lamp (Blender: Light > Color). */
48
+ color?: Vec3
49
+ /** Lamp power in Blender units (Blender: Light > Strength). */
50
+ strength?: number
51
+ /** Direction sunlight travels (points FROM sun TO scene, Blender: -light.rotation.Z). */
52
+ direction?: Vec3
53
+ }
54
+
55
+ export type CameraOptions = {
56
+ /** Orbit distance from target. */
57
+ distance?: number
58
+ /** World-space orbit center. */
59
+ target?: Vec3
60
+ /** Vertical field of view in radians. */
61
+ fov?: number
62
+ }
63
+
64
+ /** EEVEE Bloom panel (3D Viewport > Render > Bloom). Fields map 1:1 to Blender's UI. */
65
+ export type BloomOptions = {
66
+ enabled: boolean
67
+ threshold: number
68
+ knee: number
69
+ radius: number
70
+ color: Vec3
71
+ intensity: number
72
+ clamp: number
73
+ }
74
+
75
+ export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
76
+ enabled: true,
77
+ threshold: 0.5,
78
+ knee: 0.5,
79
+ radius: 4.0,
80
+ color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
81
+ intensity: 0.03,
82
+ clamp: 0.0,
83
+ }
84
+
85
+ /** Blender Color Management / View (rendering.txt: Filmic, exposure, gamma). `look` is reserved for future curve tweaks. */
86
+ export type ViewTransformOptions = {
87
+ /** Stops applied before Filmic: `linear *= 2^exposure` (Blender default often ~−0.3). */
88
+ exposure: number
89
+ /** After Filmic, display gamma (`pow(rgb, 1/gamma)`). */
90
+ gamma: number
91
+ look: "default" | "medium_high_contrast"
92
+ }
93
+
94
+ export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
95
+ exposure: -0.30000001192092896,
96
+ gamma: 1.0,
97
+ look: "medium_high_contrast",
98
+ }
99
+
9
100
  export type EngineOptions = {
10
- ambientColor?: Vec3
11
- directionalLightIntensity?: number
12
- minSpecularIntensity?: number
13
- rimLightIntensity?: number
14
- cameraDistance?: number
15
- cameraTarget?: Vec3
16
- cameraFov?: number
101
+ world?: WorldOptions
102
+ sun?: SunOptions
103
+ camera?: CameraOptions
104
+ /** Initial EEVEE-style bloom; tune at runtime with `setBloomOptions`. */
105
+ bloom?: Partial<BloomOptions>
106
+ /** View transform (exposure/gamma) applied in composite before/after Filmic. */
107
+ view?: Partial<ViewTransformOptions>
17
108
  onRaycast?: RaycastCallback
18
109
  physicsOptions?: PhysicsOptions
19
- shadowLightDirection?: Vec3
20
110
  }
21
111
 
22
112
  export const DEFAULT_ENGINE_OPTIONS = {
23
- ambientColor: new Vec3(0.88, 0.88, 0.88),
24
- directionalLightIntensity: 0.24,
25
- minSpecularIntensity: 0.3,
26
- rimLightIntensity: 0.4,
27
- cameraDistance: 26.6,
28
- cameraTarget: new Vec3(0, 12.5, 0),
29
- cameraFov: Math.PI / 4,
113
+ world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
114
+ sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 2.0, direction: new Vec3(-0.0873, -0.3844, 0.919) },
115
+ camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
30
116
  onRaycast: undefined,
31
117
  physicsOptions: { constraintSolverKeywords: ["胸"] },
32
- shadowLightDirection: new Vec3(0.12, -1, 0.16),
33
118
  }
34
119
 
35
120
  export interface EngineStats {
@@ -37,12 +122,7 @@ export interface EngineStats {
37
122
  frameTime: number // ms
38
123
  }
39
124
 
40
- type DrawCallType =
41
- | "opaque"
42
- | "transparent"
43
- | "ground"
44
- | "opaque-outline"
45
- | "transparent-outline"
125
+ type DrawCallType = "opaque" | "transparent" | "ground" | "opaque-outline" | "transparent-outline"
46
126
 
47
127
  interface DrawCall {
48
128
  type: DrawCallType
@@ -50,6 +130,7 @@ interface DrawCall {
50
130
  firstIndex: number
51
131
  bindGroup: GPUBindGroup
52
132
  materialName: string
133
+ preset: MaterialPreset
53
134
  }
54
135
 
55
136
  interface PickDrawCall {
@@ -62,6 +143,9 @@ interface ModelInstance {
62
143
  name: string
63
144
  model: Model
64
145
  basePath: string
146
+ assetReader: AssetReader
147
+ gpuBuffers: GPUBuffer[]
148
+ textureCacheKeys: string[]
65
149
  vertexBuffer: GPUBuffer
66
150
  indexBuffer: GPUBuffer
67
151
  jointsBuffer: GPUBuffer
@@ -74,6 +158,7 @@ interface ModelInstance {
74
158
  pickPerInstanceBindGroup: GPUBindGroup
75
159
  pickDrawCalls: PickDrawCall[]
76
160
  hiddenMaterials: Set<string>
161
+ materialPresets: MaterialPresetMap | undefined
77
162
  physics: Physics | null
78
163
  vertexBufferNeedsUpdate: boolean
79
164
  }
@@ -95,15 +180,24 @@ export class Engine {
95
180
  private camera!: Camera
96
181
  private cameraUniformBuffer!: GPUBuffer
97
182
  private cameraMatrixData = new Float32Array(36)
98
- private cameraDistance!: number
99
- private cameraTarget!: Vec3
100
- private cameraFov!: number
183
+ // Blender-style scene config groups (resolved from EngineOptions)
184
+ private world!: { color: Vec3; strength: number }
185
+ private sun!: { color: Vec3; strength: number; direction: Vec3 }
186
+ private cameraConfig!: { distance: number; target: Vec3; fov: number }
101
187
  private lightUniformBuffer!: GPUBuffer
102
188
  private lightData = new Float32Array(64)
103
189
  private lightCount = 0
104
190
  private resizeObserver: ResizeObserver | null = null
105
191
  private depthTexture!: GPUTexture
106
192
  private modelPipeline!: GPURenderPipeline
193
+ private facePipeline!: GPURenderPipeline
194
+ private hairPipeline!: GPURenderPipeline
195
+ private clothSmoothPipeline!: GPURenderPipeline
196
+ private clothRoughPipeline!: GPURenderPipeline
197
+ private metalPipeline!: GPURenderPipeline
198
+ private bodyPipeline!: GPURenderPipeline
199
+ private eyePipeline!: GPURenderPipeline
200
+ private stockingsPipeline!: GPURenderPipeline
107
201
  private groundShadowPipeline!: GPURenderPipeline
108
202
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
109
203
  private outlinePipeline!: GPURenderPipeline
@@ -115,22 +209,58 @@ export class Engine {
115
209
  private perFrameBindGroup!: GPUBindGroup
116
210
  private outlinePerFrameBindGroup!: GPUBindGroup
117
211
  private multisampleTexture!: GPUTexture
212
+ private hdrResolveTexture!: GPUTexture
118
213
  private static readonly MULTISAMPLE_COUNT = 4
214
+ private static readonly HDR_FORMAT: GPUTextureFormat = "rgba16float"
119
215
  private renderPassDescriptor!: GPURenderPassDescriptor
120
-
121
- // Ambient light settings
122
- private ambientColor!: Vec3
123
- private directionalLightIntensity!: number
124
- private minSpecularIntensity!: number
125
- // Rim light settings
126
- private rimLightIntensity!: number
216
+ private compositePassDescriptor!: GPURenderPassDescriptor
217
+ private compositePipeline!: GPURenderPipeline
218
+ private compositeBindGroupLayout!: GPUBindGroupLayout
219
+ private compositeBindGroup!: GPUBindGroup
220
+ private compositeUniformBuffer!: GPUBuffer
221
+ // [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
222
+ private readonly compositeUniformData = new Float32Array(8)
223
+
224
+ // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
225
+ // blit (HDR → half-res, 4-tap Karis + soft threshold/knee)
226
+ // N-1 downsamples (13-tap Jimenez/COD box filter, 5 group averages)
227
+ // N-1 upsamples (9-tap tent, additively combined with corresponding downsample mip)
228
+ // composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
229
+ // Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
230
+ private bloomSampler!: GPUSampler
231
+ private bloomBlitUniformBuffer!: GPUBuffer
232
+ private bloomUpsampleUniformBuffer!: GPUBuffer
233
+ private readonly bloomBlitUniformData = new Float32Array(4)
234
+ private readonly bloomUpsampleUniformData = new Float32Array(4)
235
+ private bloomBlitPipeline!: GPURenderPipeline
236
+ private bloomDownsamplePipeline!: GPURenderPipeline
237
+ private bloomUpsamplePipeline!: GPURenderPipeline
238
+ private bloomBlitBindGroupLayout!: GPUBindGroupLayout
239
+ private bloomDownsampleBindGroupLayout!: GPUBindGroupLayout
240
+ private bloomUpsampleBindGroupLayout!: GPUBindGroupLayout
241
+ private bloomDownTexture!: GPUTexture
242
+ private bloomUpTexture!: GPUTexture
243
+ private bloomMipCount = 0
244
+ private bloomDownMipViews: GPUTextureView[] = []
245
+ private bloomUpMipViews: GPUTextureView[] = []
246
+ private bloomBlitBindGroup!: GPUBindGroup
247
+ private bloomDownsampleBindGroups: GPUBindGroup[] = []
248
+ private bloomUpsampleBindGroups: GPUBindGroup[] = []
249
+ /** Single-attachment pass; colorAttachments[0].view set per bloom step. */
250
+ private bloomPassDescriptor!: GPURenderPassDescriptor
251
+ private static readonly BLOOM_MAX_LEVELS = 7
127
252
 
128
253
  // Ground properties (shadow only)
129
254
  private groundVertexBuffer?: GPUBuffer
130
255
  private groundIndexBuffer?: GPUBuffer
131
256
  private hasGround = false
132
- private shadowMapTexture?: GPUTexture
133
- private shadowMapDepthView?: GPUTextureView
257
+ private shadowMapTexture!: GPUTexture
258
+ private shadowMapDepthView!: GPUTextureView
259
+ private dfgLutTexture!: GPUTexture
260
+ private dfgLutView!: GPUTextureView
261
+ private ltcMagLutTexture!: GPUTexture
262
+ private ltcMagLutView!: GPUTextureView
263
+ private static readonly SHADOW_MAP_SIZE = 4096
134
264
  private shadowDepthPipeline!: GPURenderPipeline
135
265
  private shadowLightVPBuffer!: GPUBuffer
136
266
  private shadowLightVPMatrix = new Float32Array(16)
@@ -141,7 +271,6 @@ export class Engine {
141
271
 
142
272
  private onRaycast?: RaycastCallback
143
273
  private physicsOptions: PhysicsOptions = DEFAULT_ENGINE_OPTIONS.physicsOptions
144
- private shadowLightDirection: Vec3 = DEFAULT_ENGINE_OPTIONS.shadowLightDirection
145
274
  private lastTouchTime = 0
146
275
  private readonly DOUBLE_TAP_DELAY = 300
147
276
  // GPU picking
@@ -180,24 +309,140 @@ export class Engine {
180
309
  }
181
310
  private animationFrameId: number | null = null
182
311
  private renderLoopCallback: (() => void) | null = null
312
+ private bloomSettings!: BloomOptions
313
+ private viewTransform!: ViewTransformOptions
183
314
 
184
315
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
185
316
  this.canvas = canvas
186
- if (options) {
187
- this.ambientColor = options.ambientColor ?? DEFAULT_ENGINE_OPTIONS.ambientColor
188
- this.directionalLightIntensity =
189
- options.directionalLightIntensity ?? DEFAULT_ENGINE_OPTIONS.directionalLightIntensity
190
- this.minSpecularIntensity = options.minSpecularIntensity ?? DEFAULT_ENGINE_OPTIONS.minSpecularIntensity
191
- this.rimLightIntensity = options.rimLightIntensity ?? DEFAULT_ENGINE_OPTIONS.rimLightIntensity
192
- this.cameraDistance = options.cameraDistance ?? DEFAULT_ENGINE_OPTIONS.cameraDistance
193
- this.cameraTarget = options.cameraTarget ?? DEFAULT_ENGINE_OPTIONS.cameraTarget
194
- this.cameraFov = options.cameraFov ?? DEFAULT_ENGINE_OPTIONS.cameraFov
195
- this.onRaycast = options.onRaycast
196
- this.physicsOptions = options.physicsOptions ?? DEFAULT_ENGINE_OPTIONS.physicsOptions
197
- this.shadowLightDirection = options.shadowLightDirection ?? DEFAULT_ENGINE_OPTIONS.shadowLightDirection
317
+ const d = DEFAULT_ENGINE_OPTIONS
318
+ this.world = {
319
+ color: options?.world?.color ?? d.world.color,
320
+ strength: options?.world?.strength ?? d.world.strength,
321
+ }
322
+ this.sun = {
323
+ color: options?.sun?.color ?? d.sun.color,
324
+ strength: options?.sun?.strength ?? d.sun.strength,
325
+ direction: options?.sun?.direction ?? d.sun.direction,
326
+ }
327
+ this.cameraConfig = {
328
+ distance: options?.camera?.distance ?? d.camera.distance,
329
+ target: options?.camera?.target ?? d.camera.target,
330
+ fov: options?.camera?.fov ?? d.camera.fov,
331
+ }
332
+ this.onRaycast = options?.onRaycast
333
+ this.physicsOptions = options?.physicsOptions ?? d.physicsOptions
334
+ this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom)
335
+ this.viewTransform = Engine.mergeViewTransformDefaults(options?.view)
336
+ }
337
+
338
+ /** Merge partial bloom with EEVEE defaults (same as constructor). */
339
+ static mergeBloomDefaults(partial?: Partial<BloomOptions>): BloomOptions {
340
+ const d = DEFAULT_BLOOM_OPTIONS
341
+ const c = partial?.color
342
+ return {
343
+ enabled: partial?.enabled ?? d.enabled,
344
+ threshold: partial?.threshold ?? d.threshold,
345
+ knee: partial?.knee ?? d.knee,
346
+ radius: partial?.radius ?? d.radius,
347
+ color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
348
+ intensity: partial?.intensity ?? d.intensity,
349
+ clamp: partial?.clamp ?? d.clamp,
198
350
  }
199
351
  }
200
352
 
353
+ static mergeViewTransformDefaults(partial?: Partial<ViewTransformOptions>): ViewTransformOptions {
354
+ const d = DEFAULT_VIEW_TRANSFORM
355
+ return {
356
+ exposure: partial?.exposure ?? d.exposure,
357
+ gamma: partial?.gamma ?? d.gamma,
358
+ look: partial?.look ?? d.look,
359
+ }
360
+ }
361
+
362
+ /** Current bloom settings (Blender names; tint is a copied `Vec3`). */
363
+ getBloomOptions(): BloomOptions {
364
+ const b = this.bloomSettings
365
+ return {
366
+ enabled: b.enabled,
367
+ threshold: b.threshold,
368
+ knee: b.knee,
369
+ radius: b.radius,
370
+ color: new Vec3(b.color.x, b.color.y, b.color.z),
371
+ intensity: b.intensity,
372
+ clamp: b.clamp,
373
+ }
374
+ }
375
+
376
+ getViewTransformOptions(): ViewTransformOptions {
377
+ const v = this.viewTransform
378
+ return { exposure: v.exposure, gamma: v.gamma, look: v.look }
379
+ }
380
+
381
+ setViewTransformOptions(patch: Partial<ViewTransformOptions>): void {
382
+ const v = this.viewTransform
383
+ if (patch.exposure !== undefined) v.exposure = patch.exposure
384
+ if (patch.gamma !== undefined) v.gamma = patch.gamma
385
+ if (patch.look !== undefined) v.look = patch.look
386
+ if (this.device && this.compositeUniformBuffer) {
387
+ this.writeCompositeViewUniforms()
388
+ }
389
+ }
390
+
391
+ private writeCompositeViewUniforms(): void {
392
+ const v = this.viewTransform
393
+ const b = this.bloomSettings
394
+ const effIntensity = b.enabled ? b.intensity : 0.0
395
+ const u = this.compositeUniformData
396
+ u[0] = v.exposure
397
+ u[1] = Math.max(v.gamma, 1e-4)
398
+ u[2] = 0.0
399
+ u[3] = 0.0
400
+ u[4] = b.color.x
401
+ u[5] = b.color.y
402
+ u[6] = b.color.z
403
+ u[7] = effIntensity
404
+ this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
405
+ }
406
+
407
+ /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
408
+ setBloomOptions(patch: Partial<BloomOptions>): void {
409
+ const b = this.bloomSettings
410
+ if (patch.enabled !== undefined) b.enabled = patch.enabled
411
+ if (patch.threshold !== undefined) b.threshold = patch.threshold
412
+ if (patch.knee !== undefined) b.knee = patch.knee
413
+ if (patch.radius !== undefined) b.radius = patch.radius
414
+ if (patch.color !== undefined) {
415
+ b.color.x = patch.color.x
416
+ b.color.y = patch.color.y
417
+ b.color.z = patch.color.z
418
+ }
419
+ if (patch.intensity !== undefined) b.intensity = patch.intensity
420
+ if (patch.clamp !== undefined) b.clamp = patch.clamp
421
+ if (this.device && this.bloomBlitUniformBuffer) {
422
+ this.writeBloomUniforms()
423
+ this.writeCompositeViewUniforms()
424
+ }
425
+ }
426
+
427
+ // EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
428
+ private writeBloomUniforms(): void {
429
+ const b = this.bloomSettings
430
+ const bu = this.bloomBlitUniformData
431
+ // EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
432
+ bu[0] = b.threshold
433
+ bu[1] = b.knee
434
+ bu[2] = b.clamp
435
+ bu[3] = 0.0
436
+ this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu)
437
+ const us = this.bloomUpsampleUniformData
438
+ // Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
439
+ us[0] = Math.max(0.5, b.radius)
440
+ us[1] = 0
441
+ us[2] = 0
442
+ us[3] = 0
443
+ this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us)
444
+ }
445
+
201
446
  // Step 1: Get WebGPU device and context
202
447
  async init() {
203
448
  const adapter = await navigator.gpu?.requestAdapter()
@@ -228,6 +473,78 @@ export class Engine {
228
473
  Engine.instance = this
229
474
  }
230
475
 
476
+ // One-shot bake of EEVEE's BRDF split-sum DFG LUT — ported from
477
+ // bsdf_lut_frag.glsl. Runs once per engine init; resulting 64×64 rg16float
478
+ // texture is sampled by every material shader via group(0) binding(9).
479
+ private bakeDfgLut() {
480
+ this.dfgLutTexture = this.device.createTexture({
481
+ label: "DFG LUT",
482
+ size: [DFG_LUT_SIZE, DFG_LUT_SIZE],
483
+ format: "rg16float",
484
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
485
+ })
486
+ this.dfgLutView = this.dfgLutTexture.createView()
487
+
488
+ const module = this.device.createShaderModule({ label: "DFG LUT bake", code: DFG_LUT_WGSL })
489
+ const pipeline = this.device.createRenderPipeline({
490
+ label: "DFG LUT bake pipeline",
491
+ layout: "auto",
492
+ vertex: { module, entryPoint: "vs" },
493
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rg16float" }] },
494
+ primitive: { topology: "triangle-list" },
495
+ })
496
+
497
+ const enc = this.device.createCommandEncoder({ label: "DFG LUT bake encoder" })
498
+ const pass = enc.beginRenderPass({
499
+ colorAttachments: [
500
+ { view: this.dfgLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
501
+ ],
502
+ })
503
+ pass.setPipeline(pipeline)
504
+ pass.draw(3, 1, 0, 0)
505
+ pass.end()
506
+ this.device.queue.submit([enc.finish()])
507
+ }
508
+
509
+ // Upload Blender's static LTC GGX magnitude LUT (eevee_lut.c ltc_mag_ggx[]).
510
+ // Pairs with the DFG LUT to form ltc_brdf_scale — closure_eval_glossy_lib.glsl:79-81.
511
+ private uploadLtcMagLut() {
512
+ this.ltcMagLutTexture = this.device.createTexture({
513
+ label: "LTC mag LUT",
514
+ size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
515
+ format: "rg16float",
516
+ usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
517
+ })
518
+ this.ltcMagLutView = this.ltcMagLutTexture.createView()
519
+
520
+ // Float32 → float16 bits. rg16float writeTexture expects packed half floats.
521
+ const n = LTC_MAG_LUT_DATA.length
522
+ const half = new Uint16Array(n)
523
+ const f32 = new Float32Array(1)
524
+ const u32 = new Uint32Array(f32.buffer)
525
+ for (let i = 0; i < n; i++) {
526
+ f32[0] = LTC_MAG_LUT_DATA[i]
527
+ const x = u32[0]
528
+ const sign = (x >>> 16) & 0x8000
529
+ let exp = ((x >>> 23) & 0xff) - 127 + 15
530
+ let mant = x & 0x7fffff
531
+ if (exp <= 0) {
532
+ half[i] = sign // flush tiny values to signed zero (data here is in [0, ~1])
533
+ } else if (exp >= 31) {
534
+ half[i] = sign | 0x7c00 // inf
535
+ } else {
536
+ half[i] = sign | (exp << 10) | (mant >>> 13)
537
+ }
538
+ }
539
+
540
+ this.device.queue.writeTexture(
541
+ { texture: this.ltcMagLutTexture },
542
+ half,
543
+ { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE },
544
+ { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 }
545
+ )
546
+ }
547
+
231
548
  private createRenderPipeline(config: {
232
549
  label: string
233
550
  layout: GPUPipelineLayout
@@ -305,8 +622,11 @@ export class Engine {
305
622
  },
306
623
  ]
307
624
 
625
+ // Internal scene passes render into the HDR offscreen target; only the final
626
+ // composite pass writes the swapchain. Tonemap moved to composite so bloom
627
+ // (added next) can run on linear HDR.
308
628
  const standardBlend: GPUColorTargetState = {
309
- format: this.presentationFormat,
629
+ format: Engine.HDR_FORMAT,
310
630
  blend: {
311
631
  color: {
312
632
  srcFactor: "src-alpha",
@@ -322,146 +642,68 @@ export class Engine {
322
642
  }
323
643
 
324
644
  const shaderModule = this.device.createShaderModule({
325
- label: "model shaders",
326
- code: /* wgsl */ `
327
- struct CameraUniforms {
328
- view: mat4x4f,
329
- projection: mat4x4f,
330
- viewPos: vec3f,
331
- _padding: f32,
332
- };
333
-
334
- struct Light {
335
- direction: vec4f,
336
- color: vec4f,
337
- };
338
-
339
- struct LightUniforms {
340
- ambientColor: vec4f,
341
- lights: array<Light, 4>,
342
- };
343
-
344
- struct MaterialUniforms {
345
- alpha: f32,
346
- rimIntensity: f32,
347
- shininess: f32,
348
- _padding1: f32,
349
- rimColor: vec3f,
350
- _padding2: f32,
351
- diffuseColor: vec3f,
352
- _padding3: f32,
353
- ambientColor: vec3f,
354
- _padding4: f32,
355
- specularColor: vec3f,
356
- _padding5: f32,
357
- };
358
-
359
- struct VertexOutput {
360
- @builtin(position) position: vec4f,
361
- @location(0) normal: vec3f,
362
- @location(1) uv: vec2f,
363
- @location(2) worldPos: vec3f,
364
- };
645
+ label: "default model shader",
646
+ code: DEFAULT_SHADER_WGSL,
647
+ })
365
648
 
366
- // group 0: per-frame (bound once per pass)
367
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
368
- @group(0) @binding(1) var<uniform> light: LightUniforms;
369
- @group(0) @binding(2) var diffuseSampler: sampler;
370
- // group 1: per-instance (bound once per model)
371
- @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
372
- // group 2: per-material (bound per draw call)
373
- @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
374
- @group(2) @binding(1) var<uniform> material: MaterialUniforms;
649
+ const faceShaderModule = this.device.createShaderModule({
650
+ label: "face NPR shader",
651
+ code: FACE_SHADER_WGSL,
652
+ })
375
653
 
376
- @vertex fn vs(
377
- @location(0) position: vec3f,
378
- @location(1) normal: vec3f,
379
- @location(2) uv: vec2f,
380
- @location(3) joints0: vec4<u32>,
381
- @location(4) weights0: vec4<f32>
382
- ) -> VertexOutput {
383
- var output: VertexOutput;
384
- let pos4 = vec4f(position, 1.0);
385
-
386
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
387
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
388
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
389
-
390
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
391
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
392
- for (var i = 0u; i < 4u; i++) {
393
- let j = joints0[i];
394
- let w = normalizedWeights[i];
395
- let m = skinMats[j];
396
- skinnedPos += (m * pos4) * w;
397
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
398
- skinnedNrm += (r3 * normal) * w;
399
- }
400
- let worldPos = skinnedPos.xyz;
401
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
402
- output.normal = normalize(skinnedNrm);
403
- output.uv = uv;
404
- output.worldPos = worldPos;
405
- return output;
406
- }
654
+ const hairShaderModule = this.device.createShaderModule({
655
+ label: "hair NPR shader",
656
+ code: HAIR_SHADER_WGSL,
657
+ })
407
658
 
408
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
409
- let finalAlpha = material.alpha;
410
- if (finalAlpha < 0.001) {
411
- discard;
412
- }
413
-
414
- let n = normalize(input.normal);
415
- let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
659
+ const clothSmoothShaderModule = this.device.createShaderModule({
660
+ label: "cloth smooth NPR shader",
661
+ code: CLOTH_SMOOTH_SHADER_WGSL,
662
+ })
416
663
 
417
- let viewDir = normalize(camera.viewPos - input.worldPos);
664
+ const clothRoughShaderModule = this.device.createShaderModule({
665
+ label: "cloth rough NPR shader",
666
+ code: CLOTH_ROUGH_SHADER_WGSL,
667
+ })
418
668
 
419
- let albedo = textureColor * material.diffuseColor;
420
-
421
- let minSpec = light.ambientColor.w;
422
- let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
423
- let specPower = max(material.shininess, 1.0);
424
-
425
- let l = -light.lights[0].direction.xyz;
426
- let nDotL = max(dot(n, l), 0.0);
427
- let intensity = light.lights[0].color.w;
428
- let radiance = light.lights[0].color.xyz * intensity;
429
-
430
- let lightAccum = light.ambientColor.xyz + radiance * nDotL;
669
+ const metalShaderModule = this.device.createShaderModule({
670
+ label: "metal NPR shader",
671
+ code: METAL_SHADER_WGSL,
672
+ })
431
673
 
432
- let h = normalize(l + viewDir);
433
- let nDotH = max(dot(n, h), 0.0);
434
- let specFactor = pow(nDotH, specPower);
435
- let specularAccum = effectiveSpecular * radiance * specFactor * nDotL;
436
-
437
- let litColor = albedo * lightAccum;
674
+ const bodyShaderModule = this.device.createShaderModule({
675
+ label: "body NPR shader",
676
+ code: BODY_SHADER_WGSL,
677
+ })
438
678
 
439
- let fresnel = 1.0 - abs(dot(n, viewDir));
440
- let rimFactor = pow(fresnel, 4.0);
441
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
679
+ const eyeShaderModule = this.device.createShaderModule({
680
+ label: "eye shader",
681
+ code: EYE_SHADER_WGSL,
682
+ })
442
683
 
443
- let color = litColor + specularAccum + rimLight;
444
-
445
- return vec4f(color, finalAlpha);
446
- }
447
- `,
684
+ const stockingsShaderModule = this.device.createShaderModule({
685
+ label: "stockings NPR shader",
686
+ code: STOCKINGS_SHADER_WGSL,
448
687
  })
449
688
 
450
- // group 0: per-frame (camera + light + sampler) — bound once per pass
689
+ // group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
451
690
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
452
691
  label: "main per-frame bind group layout",
453
692
  entries: [
454
693
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
455
694
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
456
695
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
696
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
697
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
698
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
699
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
700
+ { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
457
701
  ],
458
702
  })
459
703
  // group 1: per-instance (skinMats) — bound once per model
460
704
  this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
461
705
  label: "main per-instance bind group layout",
462
- entries: [
463
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
464
- ],
706
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
465
707
  })
466
708
  // group 2: per-material (texture + material uniforms) — bound per draw call
467
709
  this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
@@ -474,19 +716,15 @@ export class Engine {
474
716
 
475
717
  const mainPipelineLayout = this.device.createPipelineLayout({
476
718
  label: "main pipeline layout",
477
- bindGroupLayouts: [this.mainPerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.mainPerMaterialBindGroupLayout],
478
- })
479
-
480
- this.perFrameBindGroup = this.device.createBindGroup({
481
- label: "main per-frame bind group",
482
- layout: this.mainPerFrameBindGroupLayout,
483
- entries: [
484
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
485
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
486
- { binding: 2, resource: this.materialSampler },
719
+ bindGroupLayouts: [
720
+ this.mainPerFrameBindGroupLayout,
721
+ this.mainPerInstanceBindGroupLayout,
722
+ this.mainPerMaterialBindGroupLayout,
487
723
  ],
488
724
  })
489
725
 
726
+ // perFrameBindGroup is created after shadow resources below
727
+
490
728
  this.modelPipeline = this.createRenderPipeline({
491
729
  label: "model pipeline",
492
730
  layout: mainPipelineLayout,
@@ -501,6 +739,118 @@ export class Engine {
501
739
  },
502
740
  })
503
741
 
742
+ this.facePipeline = this.createRenderPipeline({
743
+ label: "face NPR pipeline",
744
+ layout: mainPipelineLayout,
745
+ shaderModule: faceShaderModule,
746
+ vertexBuffers: fullVertexBuffers,
747
+ fragmentTarget: standardBlend,
748
+ cullMode: "none",
749
+ depthStencil: {
750
+ format: "depth24plus-stencil8",
751
+ depthWriteEnabled: true,
752
+ depthCompare: "less-equal",
753
+ },
754
+ })
755
+
756
+ this.hairPipeline = this.createRenderPipeline({
757
+ label: "hair NPR pipeline",
758
+ layout: mainPipelineLayout,
759
+ shaderModule: hairShaderModule,
760
+ vertexBuffers: fullVertexBuffers,
761
+ fragmentTarget: standardBlend,
762
+ cullMode: "none",
763
+ depthStencil: {
764
+ format: "depth24plus-stencil8",
765
+ depthWriteEnabled: true,
766
+ depthCompare: "less-equal",
767
+ },
768
+ })
769
+
770
+ this.clothSmoothPipeline = this.createRenderPipeline({
771
+ label: "cloth smooth NPR pipeline",
772
+ layout: mainPipelineLayout,
773
+ shaderModule: clothSmoothShaderModule,
774
+ vertexBuffers: fullVertexBuffers,
775
+ fragmentTarget: standardBlend,
776
+ cullMode: "none",
777
+ depthStencil: {
778
+ format: "depth24plus-stencil8",
779
+ depthWriteEnabled: true,
780
+ depthCompare: "less-equal",
781
+ },
782
+ })
783
+
784
+ this.clothRoughPipeline = this.createRenderPipeline({
785
+ label: "cloth rough NPR pipeline",
786
+ layout: mainPipelineLayout,
787
+ shaderModule: clothRoughShaderModule,
788
+ vertexBuffers: fullVertexBuffers,
789
+ fragmentTarget: standardBlend,
790
+ cullMode: "none",
791
+ depthStencil: {
792
+ format: "depth24plus-stencil8",
793
+ depthWriteEnabled: true,
794
+ depthCompare: "less-equal",
795
+ },
796
+ })
797
+
798
+ this.metalPipeline = this.createRenderPipeline({
799
+ label: "metal NPR pipeline",
800
+ layout: mainPipelineLayout,
801
+ shaderModule: metalShaderModule,
802
+ vertexBuffers: fullVertexBuffers,
803
+ fragmentTarget: standardBlend,
804
+ cullMode: "none",
805
+ depthStencil: {
806
+ format: "depth24plus-stencil8",
807
+ depthWriteEnabled: true,
808
+ depthCompare: "less-equal",
809
+ },
810
+ })
811
+
812
+ this.bodyPipeline = this.createRenderPipeline({
813
+ label: "body NPR pipeline",
814
+ layout: mainPipelineLayout,
815
+ shaderModule: bodyShaderModule,
816
+ vertexBuffers: fullVertexBuffers,
817
+ fragmentTarget: standardBlend,
818
+ cullMode: "none",
819
+ depthStencil: {
820
+ format: "depth24plus-stencil8",
821
+ depthWriteEnabled: true,
822
+ depthCompare: "less-equal",
823
+ },
824
+ })
825
+
826
+ this.eyePipeline = this.createRenderPipeline({
827
+ label: "eye pipeline",
828
+ layout: mainPipelineLayout,
829
+ shaderModule: eyeShaderModule,
830
+ vertexBuffers: fullVertexBuffers,
831
+ fragmentTarget: standardBlend,
832
+ cullMode: "none",
833
+ depthStencil: {
834
+ format: "depth24plus-stencil8",
835
+ depthWriteEnabled: true,
836
+ depthCompare: "less-equal",
837
+ },
838
+ })
839
+
840
+ this.stockingsPipeline = this.createRenderPipeline({
841
+ label: "stockings NPR pipeline",
842
+ layout: mainPipelineLayout,
843
+ shaderModule: stockingsShaderModule,
844
+ vertexBuffers: fullVertexBuffers,
845
+ fragmentTarget: standardBlend,
846
+ cullMode: "none",
847
+ depthStencil: {
848
+ format: "depth24plus-stencil8",
849
+ depthWriteEnabled: true,
850
+ depthCompare: "less-equal",
851
+ },
852
+ })
853
+
504
854
  this.shadowLightVPBuffer = this.device.createBuffer({
505
855
  size: 64,
506
856
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -549,6 +899,35 @@ export class Engine {
549
899
  magFilter: "linear",
550
900
  minFilter: "linear",
551
901
  })
902
+ this.shadowMapTexture = this.device.createTexture({
903
+ label: "shadow map",
904
+ size: [Engine.SHADOW_MAP_SIZE, Engine.SHADOW_MAP_SIZE],
905
+ format: "depth32float",
906
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
907
+ })
908
+ this.shadowMapDepthView = this.shadowMapTexture.createView()
909
+
910
+ // One-shot bake of Blender EEVEE's BRDF split-sum DFG LUT (bsdf_lut_frag.glsl).
911
+ this.bakeDfgLut()
912
+ // Upload static LTC GGX magnitude LUT for direct-specular energy compensation.
913
+ this.uploadLtcMagLut()
914
+
915
+ // Now that shadow resources exist, create the main per-frame bind group
916
+ this.perFrameBindGroup = this.device.createBindGroup({
917
+ label: "main per-frame bind group",
918
+ layout: this.mainPerFrameBindGroupLayout,
919
+ entries: [
920
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
921
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
922
+ { binding: 2, resource: this.materialSampler },
923
+ { binding: 3, resource: this.shadowMapDepthView },
924
+ { binding: 4, resource: this.shadowComparisonSampler },
925
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
926
+ { binding: 9, resource: this.dfgLutView },
927
+ { binding: 10, resource: this.ltcMagLutView },
928
+ ],
929
+ })
930
+
552
931
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
553
932
  label: "ground shadow layout",
554
933
  entries: [
@@ -679,15 +1058,17 @@ export class Engine {
679
1058
 
680
1059
  const outlinePipelineLayout = this.device.createPipelineLayout({
681
1060
  label: "outline pipeline layout",
682
- bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.outlinePerMaterialBindGroupLayout],
1061
+ bindGroupLayouts: [
1062
+ this.outlinePerFrameBindGroupLayout,
1063
+ this.mainPerInstanceBindGroupLayout,
1064
+ this.outlinePerMaterialBindGroupLayout,
1065
+ ],
683
1066
  })
684
1067
 
685
1068
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
686
1069
  label: "outline per-frame bind group",
687
1070
  layout: this.outlinePerFrameBindGroupLayout,
688
- entries: [
689
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
690
- ],
1071
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
691
1072
  })
692
1073
 
693
1074
  const outlineShaderModule = this.device.createShaderModule({
@@ -744,12 +1125,27 @@ export class Engine {
744
1125
  }
745
1126
  let worldPos = skinnedPos.xyz;
746
1127
  let worldNormal = normalize(skinnedNrm);
747
- // Screen-stable edgeline: extrusion ∝ camera distance (same idea as MMD viewers / babylon-mmd-style scaling)
748
- let camDist = max(length(camera.viewPos - worldPos), 0.25);
749
- let refDist = 30.0;
750
- let edgeScale = 0.025;
751
- let expandedPos = worldPos + worldNormal * material.edgeSize * edgeScale * (camDist / refDist);
752
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
1128
+
1129
+ // Screen-space outline extrusion — MMD-style pixel-stable edge line.
1130
+ // 1. Project position and normal-as-direction to clip space.
1131
+ // 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
1132
+ // matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
1133
+ // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
1134
+ // so the perspective divide cancels out → offset stays constant in NDC regardless
1135
+ // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
1136
+ // 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
1137
+ // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
1138
+ let viewProj = camera.projection * camera.view;
1139
+ let clipPos = viewProj * vec4f(worldPos, 1.0);
1140
+ let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
1141
+ // projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
1142
+ // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
1143
+ let aspect = camera.projection[1][1] / camera.projection[0][0];
1144
+ let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
1145
+ let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
1146
+ let edgeScale = 0.0016;
1147
+ let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
1148
+ output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
753
1149
  return output;
754
1150
  }
755
1151
 
@@ -774,6 +1170,289 @@ export class Engine {
774
1170
  },
775
1171
  })
776
1172
 
1173
+ // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
1174
+ // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
1175
+ // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
1176
+ // Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
1177
+ this.bloomSampler = this.device.createSampler({
1178
+ label: "bloom sampler",
1179
+ magFilter: "linear",
1180
+ minFilter: "linear",
1181
+ addressModeU: "clamp-to-edge",
1182
+ addressModeV: "clamp-to-edge",
1183
+ })
1184
+ this.bloomBlitUniformBuffer = this.device.createBuffer({
1185
+ label: "bloom blit uniforms",
1186
+ size: 16,
1187
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1188
+ })
1189
+ this.bloomUpsampleUniformBuffer = this.device.createBuffer({
1190
+ label: "bloom upsample uniforms",
1191
+ size: 16,
1192
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1193
+ })
1194
+
1195
+ this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
1196
+ label: "bloom blit layout",
1197
+ entries: [
1198
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1199
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1200
+ ],
1201
+ })
1202
+ this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
1203
+ label: "bloom downsample layout",
1204
+ entries: [
1205
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1206
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1207
+ ],
1208
+ })
1209
+ this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
1210
+ label: "bloom upsample layout",
1211
+ entries: [
1212
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
1213
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
1214
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1215
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1216
+ ],
1217
+ })
1218
+
1219
+ const bloomFullscreenVs = /* wgsl */ `
1220
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1221
+ let x = f32((vi & 1u) << 2u) - 1.0;
1222
+ let y = f32((vi & 2u) << 1u) - 1.0;
1223
+ return vec4f(x, y, 0.0, 1.0);
1224
+ }
1225
+ `
1226
+
1227
+ // Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
1228
+ const bloomBlitShader = this.device.createShaderModule({
1229
+ label: "bloom blit (Karis prefilter)",
1230
+ code: `${bloomFullscreenVs}
1231
+ @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1232
+ @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
1233
+
1234
+ fn luminance(c: vec3f) -> f32 {
1235
+ return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
1236
+ }
1237
+ fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
1238
+ let d = vec2<i32>(textureDimensions(hdrTex));
1239
+ let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
1240
+ let s = textureLoad(hdrTex, cc, 0);
1241
+ // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
1242
+ let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
1243
+ // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
1244
+ return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
1245
+ }
1246
+
1247
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1248
+ let dst = vec2<i32>(p.xy - vec2f(0.5));
1249
+ let base = dst * 2;
1250
+ let clampV = prefilter.z;
1251
+ let a = fetch(base + vec2<i32>(0, 0), clampV);
1252
+ let b = fetch(base + vec2<i32>(1, 0), clampV);
1253
+ let c = fetch(base + vec2<i32>(0, 1), clampV);
1254
+ let d = fetch(base + vec2<i32>(1, 1), clampV);
1255
+ // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
1256
+ let wa = 1.0 / (1.0 + luminance(a));
1257
+ let wb = 1.0 / (1.0 + luminance(b));
1258
+ let wc = 1.0 / (1.0 + luminance(c));
1259
+ let wd = 1.0 / (1.0 + luminance(d));
1260
+ let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
1261
+ // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
1262
+ let bright = max(avg.r, max(avg.g, avg.b));
1263
+ let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
1264
+ let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
1265
+ let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
1266
+ return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
1267
+ }
1268
+ `,
1269
+ })
1270
+
1271
+ // Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
1272
+ const bloomDownsampleShader = this.device.createShaderModule({
1273
+ label: "bloom downsample 13-tap",
1274
+ code: `${bloomFullscreenVs}
1275
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
1276
+ @group(0) @binding(1) var srcSamp: sampler;
1277
+
1278
+ fn samp(uv: vec2f, off: vec2f) -> vec3f {
1279
+ return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
1280
+ }
1281
+
1282
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1283
+ let srcDims = vec2f(textureDimensions(srcTex));
1284
+ let t = 1.0 / srcDims;
1285
+ // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
1286
+ let dstDims = srcDims * 0.5;
1287
+ let uv = p.xy / max(dstDims, vec2f(1.0));
1288
+ let A = samp(uv, t * vec2f(-2.0, -2.0));
1289
+ let B = samp(uv, t * vec2f( 0.0, -2.0));
1290
+ let C = samp(uv, t * vec2f( 2.0, -2.0));
1291
+ let D = samp(uv, t * vec2f(-1.0, -1.0));
1292
+ let E = samp(uv, t * vec2f( 1.0, -1.0));
1293
+ let F = samp(uv, t * vec2f(-2.0, 0.0));
1294
+ let G = samp(uv, t * vec2f( 0.0, 0.0));
1295
+ let H = samp(uv, t * vec2f( 2.0, 0.0));
1296
+ let I = samp(uv, t * vec2f(-1.0, 1.0));
1297
+ let J = samp(uv, t * vec2f( 1.0, 1.0));
1298
+ let K = samp(uv, t * vec2f(-2.0, 2.0));
1299
+ let L = samp(uv, t * vec2f( 0.0, 2.0));
1300
+ let M = samp(uv, t * vec2f( 2.0, 2.0));
1301
+ var o = (D + E + I + J) * (0.5 / 4.0);
1302
+ o = o + (A + B + G + F) * (0.125 / 4.0);
1303
+ o = o + (B + C + H + G) * (0.125 / 4.0);
1304
+ o = o + (F + G + L + K) * (0.125 / 4.0);
1305
+ o = o + (G + H + M + L) * (0.125 / 4.0);
1306
+ return vec4f(o, 1.0);
1307
+ }
1308
+ `,
1309
+ })
1310
+
1311
+ // Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
1312
+ const bloomUpsampleShader = this.device.createShaderModule({
1313
+ label: "bloom upsample 9-tap tent",
1314
+ code: `${bloomFullscreenVs}
1315
+ @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
1316
+ @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
1317
+ @group(0) @binding(2) var srcSamp: sampler;
1318
+ @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
1319
+
1320
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1321
+ let srcDims = vec2f(textureDimensions(srcTex));
1322
+ let baseDims = vec2f(textureDimensions(baseTex));
1323
+ let uv = p.xy / max(baseDims, vec2f(1.0));
1324
+ let t = upU.x / srcDims;
1325
+ var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
1326
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
1327
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
1328
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
1329
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
1330
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
1331
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
1332
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
1333
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
1334
+ o = o * (1.0 / 16.0);
1335
+ let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
1336
+ return vec4f(o + base, 1.0);
1337
+ }
1338
+ `,
1339
+ })
1340
+
1341
+ const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] })
1342
+ const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] })
1343
+ const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] })
1344
+
1345
+ this.bloomBlitPipeline = this.device.createRenderPipeline({
1346
+ label: "bloom blit pipeline",
1347
+ layout: bloomBlitLayout,
1348
+ vertex: { module: bloomBlitShader, entryPoint: "vs" },
1349
+ fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1350
+ primitive: { topology: "triangle-list" },
1351
+ })
1352
+ this.bloomDownsamplePipeline = this.device.createRenderPipeline({
1353
+ label: "bloom downsample pipeline",
1354
+ layout: bloomDownLayout,
1355
+ vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
1356
+ fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1357
+ primitive: { topology: "triangle-list" },
1358
+ })
1359
+ this.bloomUpsamplePipeline = this.device.createRenderPipeline({
1360
+ label: "bloom upsample pipeline",
1361
+ layout: bloomUpLayout,
1362
+ vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
1363
+ fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1364
+ primitive: { topology: "triangle-list" },
1365
+ })
1366
+
1367
+ // ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
1368
+ // Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
1369
+ // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1370
+ this.compositeUniformBuffer = this.device.createBuffer({
1371
+ label: "composite view uniforms",
1372
+ size: 32,
1373
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1374
+ })
1375
+ this.compositeBindGroupLayout = this.device.createBindGroupLayout({
1376
+ label: "composite bind group layout",
1377
+ entries: [
1378
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1379
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1380
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1381
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1382
+ ],
1383
+ })
1384
+
1385
+ const compositeShader = this.device.createShaderModule({
1386
+ label: "composite shader",
1387
+ code: /* wgsl */ `
1388
+ @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1389
+ @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
1390
+ @group(0) @binding(2) var bloomSamp: sampler;
1391
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
1392
+ // viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
1393
+
1394
+ fn filmic(x: f32) -> f32 {
1395
+ var lut = array<f32, 14>(
1396
+ 0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
1397
+ 0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
1398
+ );
1399
+ let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
1400
+ let i = u32(t);
1401
+ let j = min(i + 1u, 13u);
1402
+ return mix(lut[i], lut[j], t - f32(i));
1403
+ }
1404
+
1405
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1406
+ let x = f32((vi & 1u) << 2u) - 1.0;
1407
+ let y = f32((vi & 2u) << 1u) - 1.0;
1408
+ return vec4f(x, y, 0.0, 1.0);
1409
+ }
1410
+
1411
+ @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
1412
+ let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
1413
+ let a = max(hdr.a, 1e-6);
1414
+ let straight = hdr.rgb / a;
1415
+ let fullSz = vec2f(textureDimensions(hdrTex));
1416
+ let bloomSz = vec2f(textureDimensions(bloomTex));
1417
+ // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
1418
+ let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
1419
+ let tint = viewU[1].xyz;
1420
+ let intensity = viewU[1].w;
1421
+ let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
1422
+ let combined = straight + bloom;
1423
+ let exposed = combined * exp2(viewU[0].x);
1424
+ let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
1425
+ let g = max(viewU[0].y, 1e-4);
1426
+ let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
1427
+ return vec4f(disp * hdr.a, hdr.a);
1428
+ }
1429
+ `,
1430
+ })
1431
+
1432
+ this.compositePipeline = this.device.createRenderPipeline({
1433
+ label: "composite pipeline",
1434
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
1435
+ vertex: { module: compositeShader, entryPoint: "vs" },
1436
+ fragment: {
1437
+ module: compositeShader,
1438
+ entryPoint: "fs",
1439
+ targets: [{ format: this.presentationFormat }],
1440
+ },
1441
+ primitive: { topology: "triangle-list" },
1442
+ })
1443
+
1444
+ this.bloomPassDescriptor = {
1445
+ label: "bloom pass",
1446
+ colorAttachments: [
1447
+ {
1448
+ view: undefined as unknown as GPUTextureView,
1449
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1450
+ loadOp: "clear",
1451
+ storeOp: "store",
1452
+ },
1453
+ ],
1454
+ } as GPURenderPassDescriptor
1455
+
777
1456
  // GPU picking: encode (modelIndex, materialIndex) as color
778
1457
  const pickShaderModule = this.device.createShaderModule({
779
1458
  label: "pick shader",
@@ -819,34 +1498,30 @@ export class Engine {
819
1498
 
820
1499
  this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
821
1500
  label: "pick per-frame layout",
822
- entries: [
823
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
824
- ],
1501
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
825
1502
  })
826
1503
  this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
827
1504
  label: "pick per-instance layout",
828
- entries: [
829
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
830
- ],
1505
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
831
1506
  })
832
1507
  this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
833
1508
  label: "pick per-material layout",
834
- entries: [
835
- { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
836
- ],
1509
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
837
1510
  })
838
1511
 
839
1512
  const pickPipelineLayout = this.device.createPipelineLayout({
840
1513
  label: "pick pipeline layout",
841
- bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
1514
+ bindGroupLayouts: [
1515
+ this.pickPerFrameBindGroupLayout,
1516
+ this.pickPerInstanceBindGroupLayout,
1517
+ this.pickPerMaterialBindGroupLayout,
1518
+ ],
842
1519
  })
843
1520
 
844
1521
  this.pickPerFrameBindGroup = this.device.createBindGroup({
845
1522
  label: "pick per-frame bind group",
846
1523
  layout: this.pickPerFrameBindGroupLayout,
847
- entries: [
848
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
849
- ],
1524
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
850
1525
  })
851
1526
 
852
1527
  this.pickPipeline = this.device.createRenderPipeline({
@@ -872,7 +1547,6 @@ export class Engine {
872
1547
  })
873
1548
  }
874
1549
 
875
-
876
1550
  // Step 3: Setup canvas resize handling
877
1551
  private setupResize() {
878
1552
  this.resizeObserver = new ResizeObserver(() => this.handleResize())
@@ -899,13 +1573,57 @@ export class Engine {
899
1573
  this.canvas.height = height
900
1574
 
901
1575
  this.multisampleTexture = this.device.createTexture({
902
- label: "multisample render target",
1576
+ label: "multisample HDR render target",
903
1577
  size: [width, height],
904
1578
  sampleCount: Engine.MULTISAMPLE_COUNT,
905
- format: this.presentationFormat,
1579
+ format: Engine.HDR_FORMAT,
906
1580
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
907
1581
  })
908
1582
 
1583
+ this.hdrResolveTexture = this.device.createTexture({
1584
+ label: "HDR resolve target",
1585
+ size: [width, height],
1586
+ format: Engine.HDR_FORMAT,
1587
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1588
+ })
1589
+
1590
+ // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
1591
+ // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
1592
+ const bw = Math.max(1, Math.floor(width / 2))
1593
+ const bh = Math.max(1, Math.floor(height / 2))
1594
+ const shortSide = Math.max(1, Math.min(bw, bh))
1595
+ this.bloomMipCount = Math.max(
1596
+ 1,
1597
+ Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1),
1598
+ )
1599
+ this.bloomDownTexture = this.device.createTexture({
1600
+ label: "bloom down pyramid",
1601
+ size: [bw, bh],
1602
+ mipLevelCount: this.bloomMipCount,
1603
+ format: Engine.HDR_FORMAT,
1604
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1605
+ })
1606
+ this.bloomUpTexture = this.device.createTexture({
1607
+ label: "bloom up pyramid",
1608
+ size: [bw, bh],
1609
+ mipLevelCount: Math.max(1, this.bloomMipCount - 1),
1610
+ format: Engine.HDR_FORMAT,
1611
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1612
+ })
1613
+ this.bloomDownMipViews = []
1614
+ for (let i = 0; i < this.bloomMipCount; i++) {
1615
+ this.bloomDownMipViews.push(
1616
+ this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }),
1617
+ )
1618
+ }
1619
+ this.bloomUpMipViews = []
1620
+ const upLevels = Math.max(1, this.bloomMipCount - 1)
1621
+ for (let i = 0; i < upLevels; i++) {
1622
+ this.bloomUpMipViews.push(
1623
+ this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }),
1624
+ )
1625
+ }
1626
+
909
1627
  this.depthTexture = this.device.createTexture({
910
1628
  label: "depth texture",
911
1629
  size: [width, height],
@@ -918,7 +1636,7 @@ export class Engine {
918
1636
 
919
1637
  const colorAttachment: GPURenderPassColorAttachment = {
920
1638
  view: this.multisampleTexture.createView(),
921
- resolveTarget: this.context.getCurrentTexture().createView(),
1639
+ resolveTarget: this.hdrResolveTexture.createView(),
922
1640
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
923
1641
  loadOp: "clear",
924
1642
  storeOp: "store",
@@ -938,6 +1656,80 @@ export class Engine {
938
1656
  },
939
1657
  }
940
1658
 
1659
+ // Composite pass descriptor (color attachment view patched per-frame to current swapchain).
1660
+ this.compositePassDescriptor = {
1661
+ label: "composite pass",
1662
+ colorAttachments: [
1663
+ {
1664
+ view: undefined as unknown as GPUTextureView,
1665
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1666
+ loadOp: "clear",
1667
+ storeOp: "store",
1668
+ },
1669
+ ],
1670
+ }
1671
+
1672
+ this.writeBloomUniforms()
1673
+
1674
+ if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
1675
+ // Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
1676
+ this.bloomBlitBindGroup = this.device.createBindGroup({
1677
+ label: "bloom blit bind group",
1678
+ layout: this.bloomBlitBindGroupLayout,
1679
+ entries: [
1680
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
1681
+ { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
1682
+ ],
1683
+ })
1684
+ // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
1685
+ this.bloomDownsampleBindGroups = []
1686
+ for (let i = 1; i < this.bloomMipCount; i++) {
1687
+ this.bloomDownsampleBindGroups.push(
1688
+ this.device.createBindGroup({
1689
+ label: `bloom downsample ${i}`,
1690
+ layout: this.bloomDownsampleBindGroupLayout,
1691
+ entries: [
1692
+ { binding: 0, resource: this.bloomDownMipViews[i - 1] },
1693
+ { binding: 1, resource: this.bloomSampler },
1694
+ ],
1695
+ }),
1696
+ )
1697
+ }
1698
+ // Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
1699
+ // subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
1700
+ this.bloomUpsampleBindGroups = []
1701
+ const topIdx = this.bloomMipCount - 2
1702
+ for (let i = topIdx; i >= 0; i--) {
1703
+ const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1]
1704
+ this.bloomUpsampleBindGroups.push(
1705
+ this.device.createBindGroup({
1706
+ label: `bloom upsample ${i}`,
1707
+ layout: this.bloomUpsampleBindGroupLayout,
1708
+ entries: [
1709
+ { binding: 0, resource: srcView },
1710
+ { binding: 1, resource: this.bloomDownMipViews[i] },
1711
+ { binding: 2, resource: this.bloomSampler },
1712
+ { binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
1713
+ ],
1714
+ }),
1715
+ )
1716
+ }
1717
+ // Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
1718
+ const compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0]
1719
+ this.compositeBindGroup = this.device.createBindGroup({
1720
+ label: "composite bind group",
1721
+ layout: this.compositeBindGroupLayout,
1722
+ entries: [
1723
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
1724
+ { binding: 1, resource: compositeBloomView },
1725
+ { binding: 2, resource: this.bloomSampler },
1726
+ { binding: 3, resource: { buffer: this.compositeUniformBuffer } },
1727
+ ],
1728
+ })
1729
+ }
1730
+
1731
+ this.writeCompositeViewUniforms()
1732
+
941
1733
  this.camera.aspect = width / height
942
1734
 
943
1735
  if (this.onRaycast) {
@@ -965,7 +1757,13 @@ export class Engine {
965
1757
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
966
1758
  })
967
1759
 
968
- this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraDistance, this.cameraTarget, this.cameraFov)
1760
+ this.camera = new Camera(
1761
+ Math.PI,
1762
+ Math.PI / 2.5,
1763
+ this.cameraConfig.distance,
1764
+ this.cameraConfig.target,
1765
+ this.cameraConfig.fov,
1766
+ )
969
1767
 
970
1768
  this.camera.aspect = this.canvas.width / this.canvas.height
971
1769
  this.camera.attachControl(this.canvas)
@@ -1007,55 +1805,92 @@ export class Engine {
1007
1805
  this.cameraTargetOffset.z = offset?.z ?? 0
1008
1806
  }
1009
1807
 
1010
- getCameraDistance(): number { return this.camera.radius }
1011
- setCameraDistance(d: number): void { this.camera.radius = d }
1012
- getCameraAlpha(): number { return this.camera.alpha }
1013
- setCameraAlpha(a: number): void { this.camera.alpha = a }
1014
- getCameraBeta(): number { return this.camera.beta }
1015
- setCameraBeta(b: number): void { this.camera.beta = b }
1808
+ getCameraDistance(): number {
1809
+ return this.camera.radius
1810
+ }
1811
+ setCameraDistance(d: number): void {
1812
+ this.camera.radius = d
1813
+ }
1814
+ getCameraAlpha(): number {
1815
+ return this.camera.alpha
1816
+ }
1817
+ setCameraAlpha(a: number): void {
1818
+ this.camera.alpha = a
1819
+ }
1820
+ getCameraBeta(): number {
1821
+ return this.camera.beta
1822
+ }
1823
+ setCameraBeta(b: number): void {
1824
+ this.camera.beta = b
1825
+ }
1016
1826
 
1017
1827
  // Step 5: Create lighting buffers
1018
1828
  private setupLighting() {
1019
1829
  this.lightUniformBuffer = this.device.createBuffer({
1020
1830
  label: "light uniforms",
1021
- size: 64 * 4, // 64 floats: ambientColor vec4f (4) + 4 lights * 2 vec4f each (32)
1831
+ size: 64 * 4, // ambientColor vec4f (4) + 4 lights * 2 vec4f each (32) = 36 f32 padded to 64
1022
1832
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1023
1833
  })
1024
-
1025
- // Initialize light buffer to zeros
1026
1834
  this.lightData.fill(0)
1027
1835
  this.lightCount = 0
1836
+ this.writeWorld()
1837
+ this.writeSun(0)
1838
+ }
1028
1839
 
1029
- this.setAmbientColor(this.ambientColor)
1030
- this.addLight(new Vec3(0.5, -1, 1).normalize(), new Vec3(1.0, 1.0, 1.0), this.directionalLightIntensity)
1840
+ /**
1841
+ * Write world ambient. For a uniform-radiance world, hemispherical irradiance
1842
+ * is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
1843
+ * shader's ambient uniform is just `world.color × world.strength` — no /π.
1844
+ */
1845
+ private writeWorld() {
1846
+ const s = this.world.strength
1847
+ this.lightData[0] = this.world.color.x * s
1848
+ this.lightData[1] = this.world.color.y * s
1849
+ this.lightData[2] = this.world.color.z * s
1850
+ this.lightData[3] = 0
1851
+ this.updateLightBuffer()
1031
1852
  }
1032
1853
 
1033
- private setAmbientColor(color: Vec3) {
1034
- // Layout: ambientColor (0-3), lights (4-63) - 2 vec4f per light
1035
- this.lightData[0] = color.x // ambientColor.x
1036
- this.lightData[1] = color.y // ambientColor.y
1037
- this.lightData[2] = color.z // ambientColor.z
1038
- this.lightData[3] = this.minSpecularIntensity // ambientColor.w = minSpecularIntensity
1854
+ /** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
1855
+ private writeSun(index: number) {
1856
+ if (index < 0 || index >= 4) return
1857
+ const normalized = this.sun.direction.normalize()
1858
+ const base = 4 + index * 8 // 8 floats per light (direction vec4, color vec4)
1859
+ this.lightData[base] = normalized.x
1860
+ this.lightData[base + 1] = normalized.y
1861
+ this.lightData[base + 2] = normalized.z
1862
+ this.lightData[base + 3] = 0
1863
+ this.lightData[base + 4] = this.sun.color.x
1864
+ this.lightData[base + 5] = this.sun.color.y
1865
+ this.lightData[base + 6] = this.sun.color.z
1866
+ this.lightData[base + 7] = this.sun.strength
1867
+ if (index >= this.lightCount) this.lightCount = index + 1
1039
1868
  this.updateLightBuffer()
1040
1869
  }
1041
1870
 
1042
- private addLight(direction: Vec3, color: Vec3, intensity: number = 1.0): boolean {
1043
- if (this.lightCount >= 4) return false
1871
+ /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
1872
+ setWorld(options: WorldOptions): void {
1873
+ if (options.color) this.world.color = options.color
1874
+ if (options.strength !== undefined) this.world.strength = options.strength
1875
+ this.writeWorld()
1876
+ }
1044
1877
 
1045
- const normalized = direction.normalize()
1046
- const baseIndex = 4 + this.lightCount * 8 // Start at index 4, 8 floats per light (2 vec4f)
1047
- this.lightData[baseIndex] = normalized.x // direction.x
1048
- this.lightData[baseIndex + 1] = normalized.y // direction.y
1049
- this.lightData[baseIndex + 2] = normalized.z // direction.z
1050
- this.lightData[baseIndex + 3] = 0 // direction.w
1051
- this.lightData[baseIndex + 4] = color.x // color.x
1052
- this.lightData[baseIndex + 5] = color.y // color.y
1053
- this.lightData[baseIndex + 6] = color.z // color.z
1054
- this.lightData[baseIndex + 7] = intensity // color.w / intensity
1878
+ /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
1879
+ setSun(options: SunOptions): void {
1880
+ if (options.color) this.sun.color = options.color
1881
+ if (options.strength !== undefined) this.sun.strength = options.strength
1882
+ if (options.direction) {
1883
+ this.sun.direction = options.direction
1884
+ this.shadowLightVPDirty = true
1885
+ }
1886
+ this.writeSun(0)
1887
+ }
1055
1888
 
1056
- this.lightCount++
1057
- this.updateLightBuffer()
1058
- return true
1889
+ getWorld(): Readonly<{ color: Vec3; strength: number }> {
1890
+ return this.world
1891
+ }
1892
+ getSun(): Readonly<{ color: Vec3; strength: number; direction: Vec3 }> {
1893
+ return this.sun
1059
1894
  }
1060
1895
 
1061
1896
  addGround(options?: {
@@ -1064,7 +1899,6 @@ export class Engine {
1064
1899
  diffuseColor?: Vec3
1065
1900
  fadeStart?: number
1066
1901
  fadeEnd?: number
1067
- shadowMapSize?: number
1068
1902
  shadowStrength?: number
1069
1903
  gridSpacing?: number
1070
1904
  gridLineWidth?: number
@@ -1075,10 +1909,9 @@ export class Engine {
1075
1909
  const opts = {
1076
1910
  width: 160,
1077
1911
  height: 160,
1078
- diffuseColor: new Vec3(0.8, 0.1, 1.0),
1912
+ diffuseColor: new Vec3(0.9, 0.1, 1.0),
1079
1913
  fadeStart: 10.0,
1080
1914
  fadeEnd: 80.0,
1081
- shadowMapSize: 4096,
1082
1915
  shadowStrength: 1.0,
1083
1916
  gridSpacing: 4.2,
1084
1917
  gridLineWidth: 0.012,
@@ -1096,6 +1929,7 @@ export class Engine {
1096
1929
  firstIndex: 0,
1097
1930
  bindGroup: this.groundShadowBindGroup!,
1098
1931
  materialName: "Ground",
1932
+ preset: "cloth_rough",
1099
1933
  }
1100
1934
  }
1101
1935
 
@@ -1151,30 +1985,59 @@ export class Engine {
1151
1985
 
1152
1986
  async loadModel(path: string): Promise<Model>
1153
1987
  async loadModel(name: string, path: string): Promise<Model>
1154
- async loadModel(nameOrPath: string, path?: string): Promise<Model> {
1155
- const pmxPath = path === undefined ? nameOrPath : path
1156
- const name = path === undefined ? "model_" + (this._nextDefaultModelId++) : nameOrPath
1988
+ async loadModel(name: string, options: LoadModelFromFilesOptions): Promise<Model>
1989
+ async loadModel(nameOrPath: string, pathOrOptions?: string | LoadModelFromFilesOptions): Promise<Model> {
1990
+ if (pathOrOptions !== undefined && typeof pathOrOptions === "object" && "files" in pathOrOptions) {
1991
+ const name = nameOrPath
1992
+ const pmxFile = pathOrOptions.pmxFile ?? findFirstPmxFileInList(pathOrOptions.files)
1993
+ if (!pmxFile) throw new Error("No .pmx file found in the selected folder")
1994
+ const map = fileListToMap(pathOrOptions.files)
1995
+ const pmxKey = normalizeAssetPath(
1996
+ (pmxFile as File & { webkitRelativePath?: string }).webkitRelativePath ?? pmxFile.name,
1997
+ )
1998
+ const reader = createFileMapAssetReader(map)
1999
+ const model = await PmxLoader.loadFromReader(reader, pmxKey)
2000
+ model.setName(name)
2001
+ await this.addModel(model, pmxKey, name, reader)
2002
+ return model
2003
+ }
2004
+
2005
+ const pmxPath = pathOrOptions === undefined ? nameOrPath : pathOrOptions
2006
+ const name = pathOrOptions === undefined ? "model_" + this._nextDefaultModelId++ : nameOrPath
1157
2007
  const model = await PmxLoader.load(pmxPath)
1158
2008
  model.setName(name)
1159
2009
  await this.addModel(model, pmxPath, name)
1160
2010
  return model
1161
2011
  }
1162
2012
 
1163
- async addModel(model: Model, pmxPath: string, name?: string): Promise<string> {
2013
+ async addModel(model: Model, pmxPath: string, name?: string, assetReader?: AssetReader): Promise<string> {
1164
2014
  const requested = name ?? model.name
1165
2015
  let key = requested
1166
2016
  let n = 1
1167
2017
  while (this.modelInstances.has(key)) {
1168
2018
  key = `${requested}_${n++}`
1169
2019
  }
1170
- const pathParts = pmxPath.split("/")
1171
- pathParts.pop()
1172
- const basePath = pathParts.join("/") + "/"
1173
- await this.setupModelInstance(key, model, basePath)
2020
+ const reader = assetReader ?? createFetchAssetReader()
2021
+ const basePath = deriveBasePathFromPmxPath(pmxPath)
2022
+ model.setAssetContext(reader, basePath)
2023
+ await this.setupModelInstance(key, model, basePath, reader)
1174
2024
  return key
1175
2025
  }
1176
2026
 
1177
2027
  removeModel(name: string): void {
2028
+ const inst = this.modelInstances.get(name)
2029
+ if (!inst) return
2030
+ inst.model.stopAnimation()
2031
+ for (const path of inst.textureCacheKeys) {
2032
+ const tex = this.textureCache.get(path)
2033
+ if (tex) {
2034
+ tex.destroy()
2035
+ this.textureCache.delete(path)
2036
+ }
2037
+ }
2038
+ for (const buf of inst.gpuBuffers) {
2039
+ buf.destroy()
2040
+ }
1178
2041
  this.modelInstances.delete(name)
1179
2042
  }
1180
2043
 
@@ -1201,6 +2064,15 @@ export class Engine {
1201
2064
  }
1202
2065
  }
1203
2066
 
2067
+ setMaterialPresets(modelName: string, presets: MaterialPresetMap): void {
2068
+ const inst = this.modelInstances.get(modelName)
2069
+ if (!inst) return
2070
+ inst.materialPresets = presets
2071
+ for (const dc of inst.drawCalls) {
2072
+ dc.preset = resolvePreset(dc.materialName, presets)
2073
+ }
2074
+ }
2075
+
1204
2076
  setMaterialVisible(modelName: string, materialName: string, visible: boolean): void {
1205
2077
  const inst = this.modelInstances.get(modelName)
1206
2078
  if (!inst) return
@@ -1245,11 +2117,7 @@ export class Engine {
1245
2117
  const verticesChanged = inst.model.update(deltaTime, this.ikEnabled)
1246
2118
  if (verticesChanged) inst.vertexBufferNeedsUpdate = true
1247
2119
  if (inst.physics && this.physicsEnabled) {
1248
- inst.physics.step(
1249
- deltaTime,
1250
- inst.model.getWorldMatrices(),
1251
- inst.model.getBoneInverseBindMatrices()
1252
- )
2120
+ inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices())
1253
2121
  }
1254
2122
  if (inst.vertexBufferNeedsUpdate) this.updateVertexBuffer(inst)
1255
2123
  })
@@ -1262,7 +2130,12 @@ export class Engine {
1262
2130
  inst.vertexBufferNeedsUpdate = false
1263
2131
  }
1264
2132
 
1265
- private async setupModelInstance(name: string, model: Model, basePath: string): Promise<void> {
2133
+ private async setupModelInstance(
2134
+ name: string,
2135
+ model: Model,
2136
+ basePath: string,
2137
+ assetReader: AssetReader,
2138
+ ): Promise<void> {
1266
2139
  const vertices = model.getVertices()
1267
2140
  const skinning = model.getSkinning()
1268
2141
  const skeleton = model.getSkeleton()
@@ -1286,7 +2159,7 @@ export class Engine {
1286
2159
  0,
1287
2160
  skinning.joints.buffer,
1288
2161
  skinning.joints.byteOffset,
1289
- skinning.joints.byteLength
2162
+ skinning.joints.byteLength,
1290
2163
  )
1291
2164
 
1292
2165
  const weightsBuffer = this.device.createBuffer({
@@ -1299,7 +2172,7 @@ export class Engine {
1299
2172
  0,
1300
2173
  skinning.weights.buffer,
1301
2174
  skinning.weights.byteOffset,
1302
- skinning.weights.byteLength
2175
+ skinning.weights.byteLength,
1303
2176
  )
1304
2177
 
1305
2178
  const skinMatrixBuffer = this.device.createBuffer({
@@ -1332,23 +2205,24 @@ export class Engine {
1332
2205
  const mainPerInstanceBindGroup = this.device.createBindGroup({
1333
2206
  label: `${name}: main per-instance bind group`,
1334
2207
  layout: this.mainPerInstanceBindGroupLayout,
1335
- entries: [
1336
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1337
- ],
2208
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1338
2209
  })
1339
2210
 
1340
2211
  const pickPerInstanceBindGroup = this.device.createBindGroup({
1341
2212
  label: `${name}: pick per-instance bind group`,
1342
2213
  layout: this.pickPerInstanceBindGroupLayout,
1343
- entries: [
1344
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1345
- ],
2214
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1346
2215
  })
1347
2216
 
2217
+ const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer]
2218
+
1348
2219
  const inst: ModelInstance = {
1349
2220
  name,
1350
2221
  model,
1351
2222
  basePath,
2223
+ assetReader,
2224
+ gpuBuffers,
2225
+ textureCacheKeys: [],
1352
2226
  vertexBuffer,
1353
2227
  indexBuffer,
1354
2228
  jointsBuffer,
@@ -1361,6 +2235,7 @@ export class Engine {
1361
2235
  pickPerInstanceBindGroup,
1362
2236
  pickDrawCalls: [],
1363
2237
  hiddenMaterials: new Set(),
2238
+ materialPresets: undefined,
1364
2239
  physics,
1365
2240
  vertexBufferNeedsUpdate: false,
1366
2241
  }
@@ -1441,7 +2316,6 @@ export class Engine {
1441
2316
  }
1442
2317
 
1443
2318
  private createShadowGroundResources(opts: {
1444
- shadowMapSize: number
1445
2319
  diffuseColor: Vec3
1446
2320
  fadeStart: number
1447
2321
  fadeEnd: number
@@ -1452,21 +2326,39 @@ export class Engine {
1452
2326
  gridLineColor: Vec3
1453
2327
  noiseStrength: number
1454
2328
  }) {
1455
- const { shadowMapSize, diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength } = opts
1456
- this.shadowMapTexture = this.device.createTexture({
1457
- label: "shadow map",
1458
- size: [shadowMapSize, shadowMapSize],
1459
- format: "depth32float",
1460
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1461
- })
1462
- this.shadowMapDepthView = this.shadowMapTexture.createView()
1463
- // Layout: diffuseColor(3f) fadeStart(1f) | fadeEnd(1f) shadowStrength(1f) pcfTexel(1f) gridSpacing(1f) | gridLineWidth(1f) gridOpacity(1f) noiseStrength(1f) _pad(1f) | gridColor(3f) _pad2(1f)
2329
+ const {
2330
+ diffuseColor,
2331
+ fadeStart,
2332
+ fadeEnd,
2333
+ shadowStrength,
2334
+ gridSpacing,
2335
+ gridLineWidth,
2336
+ gridLineOpacity,
2337
+ gridLineColor,
2338
+ noiseStrength,
2339
+ } = opts
2340
+ // Shadow map is already created in setupPipelines()
1464
2341
  const gb = new Float32Array(16)
1465
- gb[0] = diffuseColor.x; gb[1] = diffuseColor.y; gb[2] = diffuseColor.z; gb[3] = fadeStart
1466
- gb[4] = fadeEnd; gb[5] = shadowStrength; gb[6] = 1 / shadowMapSize; gb[7] = gridSpacing
1467
- gb[8] = gridLineWidth; gb[9] = gridLineOpacity; gb[10] = noiseStrength; gb[11] = 0
1468
- gb[12] = gridLineColor.x; gb[13] = gridLineColor.y; gb[14] = gridLineColor.z; gb[15] = 0
1469
- this.groundShadowMaterialBuffer = this.device.createBuffer({ size: gb.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })
2342
+ gb[0] = diffuseColor.x
2343
+ gb[1] = diffuseColor.y
2344
+ gb[2] = diffuseColor.z
2345
+ gb[3] = fadeStart
2346
+ gb[4] = fadeEnd
2347
+ gb[5] = shadowStrength
2348
+ gb[6] = 1 / Engine.SHADOW_MAP_SIZE
2349
+ gb[7] = gridSpacing
2350
+ gb[8] = gridLineWidth
2351
+ gb[9] = gridLineOpacity
2352
+ gb[10] = noiseStrength
2353
+ gb[11] = 0
2354
+ gb[12] = gridLineColor.x
2355
+ gb[13] = gridLineColor.y
2356
+ gb[14] = gridLineColor.z
2357
+ gb[15] = 0
2358
+ this.groundShadowMaterialBuffer = this.device.createBuffer({
2359
+ size: gb.byteLength,
2360
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2361
+ })
1470
2362
  this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb)
1471
2363
  this.groundShadowBindGroup = this.device.createBindGroup({
1472
2364
  label: "ground shadow bind",
@@ -1482,12 +2374,12 @@ export class Engine {
1482
2374
  })
1483
2375
  }
1484
2376
 
1485
- // Shadow uses a fixed orthographic projection, independent of the visible light direction
2377
+ // Shadow is cast from the visible sun direction — same vector the shader lights with.
1486
2378
  private shadowLightVPDirty = true
1487
2379
  private updateShadowLightVP() {
1488
2380
  if (!this.shadowLightVPDirty) return
1489
2381
  this.shadowLightVPDirty = false
1490
- const dir = new Vec3(this.shadowLightDirection.x, this.shadowLightDirection.y, this.shadowLightDirection.z)
2382
+ const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z)
1491
2383
  dir.normalize()
1492
2384
  const target = new Vec3(0, 11, 0)
1493
2385
  const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72)
@@ -1510,8 +2402,8 @@ export class Engine {
1510
2402
 
1511
2403
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1512
2404
  if (texIndex < 0 || texIndex >= textures.length) return null
1513
- const path = inst.basePath + textures[texIndex].path
1514
- return this.createTextureFromPath(path)
2405
+ const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
2406
+ return this.createTextureFromLogicalPath(inst, logicalPath)
1515
2407
  }
1516
2408
 
1517
2409
  let currentIndexOffset = 0
@@ -1527,14 +2419,12 @@ export class Engine {
1527
2419
  const materialAlpha = mat.diffuse[3]
1528
2420
  const isTransparent = materialAlpha < 1.0 - 0.001
1529
2421
 
1530
- const materialUniformBuffer = this.createMaterialUniformBuffer(
1531
- prefix + mat.name,
1532
- materialAlpha,
1533
- [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
1534
- mat.ambient,
1535
- mat.specular,
1536
- mat.shininess
1537
- )
2422
+ const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
2423
+ mat.diffuse[0],
2424
+ mat.diffuse[1],
2425
+ mat.diffuse[2],
2426
+ ])
2427
+ inst.gpuBuffers.push(materialUniformBuffer)
1538
2428
 
1539
2429
  const textureView = diffuseTexture.createView()
1540
2430
  const bindGroup = this.device.createBindGroup({
@@ -1547,28 +2437,49 @@ export class Engine {
1547
2437
  })
1548
2438
 
1549
2439
  const type: DrawCallType = isTransparent ? "transparent" : "opaque"
1550
- inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
2440
+ const preset = resolvePreset(mat.name, inst.materialPresets)
2441
+ inst.drawCalls.push({
2442
+ type,
2443
+ count: indexCount,
2444
+ firstIndex: currentIndexOffset,
2445
+ bindGroup,
2446
+ materialName: mat.name,
2447
+ preset,
2448
+ })
1551
2449
 
1552
2450
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1553
2451
  const materialUniformData = new Float32Array([
1554
- mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
1555
- mat.edgeSize, 0, 0, 0,
2452
+ mat.edgeColor[0],
2453
+ mat.edgeColor[1],
2454
+ mat.edgeColor[2],
2455
+ mat.edgeColor[3],
2456
+ mat.edgeSize,
2457
+ 0,
2458
+ 0,
2459
+ 0,
1556
2460
  ])
1557
2461
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
2462
+ inst.gpuBuffers.push(outlineUniformBuffer)
1558
2463
  const outlineBindGroup = this.device.createBindGroup({
1559
2464
  label: `${prefix}outline: ${mat.name}`,
1560
2465
  layout: this.outlinePerMaterialBindGroupLayout,
1561
- entries: [
1562
- { binding: 0, resource: { buffer: outlineUniformBuffer } },
1563
- ],
2466
+ entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
1564
2467
  })
1565
2468
  const outlineType: DrawCallType = isTransparent ? "transparent-outline" : "opaque-outline"
1566
- inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name })
2469
+ inst.drawCalls.push({
2470
+ type: outlineType,
2471
+ count: indexCount,
2472
+ firstIndex: currentIndexOffset,
2473
+ bindGroup: outlineBindGroup,
2474
+ materialName: mat.name,
2475
+ preset,
2476
+ })
1567
2477
  }
1568
2478
 
1569
2479
  if (this.onRaycast) {
1570
2480
  const pickIdData = new Float32Array([modelId, materialId, 0, 0])
1571
2481
  const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
2482
+ inst.gpuBuffers.push(pickIdBuffer)
1572
2483
  const pickBindGroup = this.device.createBindGroup({
1573
2484
  label: `${prefix}pick: ${mat.name}`,
1574
2485
  layout: this.pickPerMaterialBindGroupLayout,
@@ -1585,25 +2496,13 @@ export class Engine {
1585
2496
  }
1586
2497
  }
1587
2498
 
1588
- private createMaterialUniformBuffer(
1589
- label: string,
1590
- alpha: number,
1591
- diffuseColor: [number, number, number],
1592
- ambientColor: [number, number, number],
1593
- specularColor: [number, number, number],
1594
- shininess: number
1595
- ): GPUBuffer {
1596
- const data = new Float32Array(20)
1597
- data.set([
1598
- alpha,
1599
- this.rimLightIntensity,
1600
- shininess,
1601
- 0.0,
1602
- 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1603
- diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1604
- ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1605
- specularColor[0], specularColor[1], specularColor[2], 0.0,
1606
- ])
2499
+ private createMaterialUniformBuffer(label: string, alpha: number, diffuseColor: [number, number, number]): GPUBuffer {
2500
+ // Matches WGSL `struct MaterialUniforms { diffuseColor: vec3f, alpha: f32 }` — 16 bytes.
2501
+ const data = new Float32Array(4)
2502
+ data[0] = diffuseColor[0]
2503
+ data[1] = diffuseColor[1]
2504
+ data[2] = diffuseColor[2]
2505
+ data[3] = alpha
1607
2506
  return this.createUniformBuffer(`material uniform: ${label}`, data)
1608
2507
  }
1609
2508
 
@@ -1621,26 +2520,24 @@ export class Engine {
1621
2520
  return !inst.hiddenMaterials.has(drawCall.materialName)
1622
2521
  }
1623
2522
 
1624
- private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
1625
- const cached = this.textureCache.get(path)
2523
+ private async createTextureFromLogicalPath(inst: ModelInstance, logicalPath: string): Promise<GPUTexture | null> {
2524
+ const cacheKey = logicalPath
2525
+ const cached = this.textureCache.get(cacheKey)
1626
2526
  if (cached) {
1627
2527
  return cached
1628
2528
  }
1629
2529
 
1630
2530
  try {
1631
- const response = await fetch(path)
1632
- if (!response.ok) {
1633
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
1634
- }
1635
- const imageBitmap = await createImageBitmap(await response.blob(), {
2531
+ const buffer = await inst.assetReader.readBinary(logicalPath)
2532
+ const imageBitmap = await createImageBitmap(new Blob([buffer]), {
1636
2533
  premultiplyAlpha: "none",
1637
2534
  colorSpaceConversion: "none",
1638
2535
  })
1639
2536
 
1640
2537
  const texture = this.device.createTexture({
1641
- label: `texture: ${path}`,
2538
+ label: `texture: ${cacheKey}`,
1642
2539
  size: [imageBitmap.width, imageBitmap.height],
1643
- format: "rgba8unorm",
2540
+ format: "rgba8unorm-srgb",
1644
2541
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1645
2542
  })
1646
2543
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
@@ -1648,14 +2545,14 @@ export class Engine {
1648
2545
  imageBitmap.height,
1649
2546
  ])
1650
2547
 
1651
- this.textureCache.set(path, texture)
2548
+ this.textureCache.set(cacheKey, texture)
2549
+ inst.textureCacheKeys.push(cacheKey)
1652
2550
  return texture
1653
2551
  } catch {
1654
2552
  return null
1655
2553
  }
1656
2554
  }
1657
2555
 
1658
-
1659
2556
  private renderGround(pass: GPURenderPassEncoder) {
1660
2557
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
1661
2558
  pass.setPipeline(this.groundShadowPipeline)
@@ -1665,7 +2562,6 @@ export class Engine {
1665
2562
  pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
1666
2563
  }
1667
2564
 
1668
-
1669
2565
  private handleCanvasDoubleClick = (event: MouseEvent) => {
1670
2566
  if (!this.onRaycast || this.modelInstances.size === 0) return
1671
2567
  const rect = this.canvas.getBoundingClientRect()
@@ -1713,12 +2609,14 @@ export class Engine {
1713
2609
  if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture) return
1714
2610
 
1715
2611
  const pass = encoder.beginRenderPass({
1716
- colorAttachments: [{
1717
- view: this.pickTexture.createView(),
1718
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
1719
- loadOp: "clear",
1720
- storeOp: "store",
1721
- }],
2612
+ colorAttachments: [
2613
+ {
2614
+ view: this.pickTexture.createView(),
2615
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2616
+ loadOp: "clear",
2617
+ storeOp: "store",
2618
+ },
2619
+ ],
1722
2620
  depthStencilAttachment: {
1723
2621
  view: this.pickDepthTexture.createView(),
1724
2622
  depthClearValue: 1.0,
@@ -1750,7 +2648,7 @@ export class Engine {
1750
2648
  encoder.copyTextureToBuffer(
1751
2649
  { texture: this.pickTexture, origin: { x: Math.max(0, px), y: Math.max(0, py) } },
1752
2650
  { buffer: this.pickReadbackBuffer, bytesPerRow: 256 },
1753
- { width: 1, height: 1 }
2651
+ { width: 1, height: 1 },
1754
2652
  )
1755
2653
  }
1756
2654
 
@@ -1771,7 +2669,10 @@ export class Engine {
1771
2669
  let idx = 1
1772
2670
  let hitModel = ""
1773
2671
  for (const [name] of this.modelInstances) {
1774
- if (idx === modelId) { hitModel = name; break }
2672
+ if (idx === modelId) {
2673
+ hitModel = name
2674
+ break
2675
+ }
1775
2676
  idx++
1776
2677
  }
1777
2678
 
@@ -1785,7 +2686,10 @@ export class Engine {
1785
2686
  for (const mat of materials) {
1786
2687
  if (mat.vertexCount === 0) continue
1787
2688
  matIdx++
1788
- if (matIdx === materialId) { hitMaterial = mat.name; break }
2689
+ if (matIdx === materialId) {
2690
+ hitMaterial = mat.name
2691
+ break
2692
+ }
1789
2693
  }
1790
2694
  }
1791
2695
  }
@@ -1800,8 +2704,6 @@ export class Engine {
1800
2704
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
1801
2705
  this.lastFrameTime = currentTime
1802
2706
 
1803
- this.updateRenderTarget()
1804
-
1805
2707
  const hasModels = this.modelInstances.size > 0
1806
2708
  if (hasModels) {
1807
2709
  this.updateInstances(deltaTime)
@@ -1819,10 +2721,10 @@ export class Engine {
1819
2721
  }
1820
2722
 
1821
2723
  this.updateCameraUniforms()
1822
- if (this.hasGround) this.updateShadowLightVP()
2724
+ this.updateShadowLightVP()
1823
2725
 
1824
2726
  const encoder = this.device.createCommandEncoder()
1825
- if (hasModels && this.hasGround && this.shadowMapDepthView) {
2727
+ if (hasModels) {
1826
2728
  const sp = encoder.beginRenderPass({
1827
2729
  colorAttachments: [],
1828
2730
  depthStencilAttachment: {
@@ -1842,6 +2744,56 @@ export class Engine {
1842
2744
  if (this.hasGround) this.renderGround(pass)
1843
2745
  pass.end()
1844
2746
 
2747
+ // Bloom pyramid (EEVEE 3.6):
2748
+ // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
2749
+ // 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
2750
+ // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
2751
+ // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
2752
+ // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
2753
+ if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
2754
+ const bloomAtt = this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
2755
+
2756
+ // 1. Blit
2757
+ bloomAtt[0].view = this.bloomDownMipViews[0]
2758
+ const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor)
2759
+ pBlit.setPipeline(this.bloomBlitPipeline)
2760
+ pBlit.setBindGroup(0, this.bloomBlitBindGroup)
2761
+ pBlit.draw(3)
2762
+ pBlit.end()
2763
+
2764
+ // 2. Downsample chain
2765
+ for (let i = 1; i < this.bloomMipCount; i++) {
2766
+ bloomAtt[0].view = this.bloomDownMipViews[i]
2767
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor)
2768
+ p.setPipeline(this.bloomDownsamplePipeline)
2769
+ p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1])
2770
+ p.draw(3)
2771
+ p.end()
2772
+ }
2773
+
2774
+ // 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
2775
+ const upSteps = this.bloomUpsampleBindGroups.length
2776
+ const topIdx = this.bloomMipCount - 2
2777
+ for (let k = 0; k < upSteps; k++) {
2778
+ const levelIdx = topIdx - k // writes bloomUp[levelIdx]
2779
+ bloomAtt[0].view = this.bloomUpMipViews[levelIdx]
2780
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor)
2781
+ p.setPipeline(this.bloomUpsamplePipeline)
2782
+ p.setBindGroup(0, this.bloomUpsampleBindGroups[k])
2783
+ p.draw(3)
2784
+ p.end()
2785
+ }
2786
+ }
2787
+
2788
+ // Composite: HDR + bloom → Filmic tonemap → swapchain.
2789
+ const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2790
+ compositeAttachment.view = this.context.getCurrentTexture().createView()
2791
+ const cpass = encoder.beginRenderPass(this.compositePassDescriptor)
2792
+ cpass.setPipeline(this.compositePipeline)
2793
+ cpass.setBindGroup(0, this.compositeBindGroup)
2794
+ cpass.draw(3)
2795
+ cpass.end()
2796
+
1845
2797
  const pick = this.pendingPick
1846
2798
  if (pick && hasModels) this.renderPickPass(encoder)
1847
2799
 
@@ -1856,11 +2808,6 @@ export class Engine {
1856
2808
  this.updateStats(performance.now() - currentTime)
1857
2809
  }
1858
2810
 
1859
- private updateRenderTarget() {
1860
- const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
1861
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
1862
- }
1863
-
1864
2811
  private drawInstanceShadow(sp: GPURenderPassEncoder, inst: ModelInstance): void {
1865
2812
  sp.setBindGroup(0, inst.shadowBindGroup)
1866
2813
  sp.setVertexBuffer(0, inst.vertexBuffer)
@@ -1872,46 +2819,83 @@ export class Engine {
1872
2819
  }
1873
2820
  }
1874
2821
 
1875
- private drawOpaque(pass: GPURenderPassEncoder, inst: ModelInstance, pipeline: GPURenderPipeline): void {
1876
- pass.setPipeline(pipeline)
2822
+ private pipelineForPreset(preset: MaterialPreset): GPURenderPipeline {
2823
+ if (preset === "face") return this.facePipeline
2824
+ if (preset === "hair") return this.hairPipeline
2825
+ if (preset === "cloth_smooth") return this.clothSmoothPipeline
2826
+ if (preset === "cloth_rough") return this.clothRoughPipeline
2827
+ if (preset === "metal") return this.metalPipeline
2828
+ if (preset === "body") return this.bodyPipeline
2829
+ if (preset === "eye") return this.eyePipeline
2830
+ if (preset === "stockings") return this.stockingsPipeline
2831
+ return this.modelPipeline
2832
+ }
2833
+
2834
+ /**
2835
+ * Draw every material of a given type (`opaque` or `transparent`) using the main
2836
+ * pipeline(s). Binds the per-frame and per-instance groups once at the top of the
2837
+ * batch, then issues one draw per material. Early-outs if nothing to draw so we
2838
+ * don't waste bindings when a model has no transparents, etc.
2839
+ */
2840
+ private drawMaterials(pass: GPURenderPassEncoder, inst: ModelInstance, type: "opaque" | "transparent"): void {
2841
+ let currentPipeline: GPURenderPipeline | null = null
2842
+ let bound = false
1877
2843
  for (const draw of inst.drawCalls) {
1878
- if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1879
- pass.setBindGroup(2, draw.bindGroup)
1880
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2844
+ if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
2845
+ if (!bound) {
2846
+ pass.setBindGroup(0, this.perFrameBindGroup)
2847
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2848
+ bound = true
2849
+ }
2850
+ const pipeline = this.pipelineForPreset(draw.preset)
2851
+ if (pipeline !== currentPipeline) {
2852
+ pass.setPipeline(pipeline)
2853
+ currentPipeline = pipeline
1881
2854
  }
2855
+ pass.setBindGroup(2, draw.bindGroup)
2856
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1882
2857
  }
1883
2858
  }
1884
2859
 
1885
- private drawTransparent(pass: GPURenderPassEncoder, inst: ModelInstance, pipeline: GPURenderPipeline): void {
1886
- pass.setPipeline(pipeline)
2860
+ /**
2861
+ * Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
2862
+ * Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
2863
+ * every batch binds its own groups from scratch — the next drawMaterials call will
2864
+ * rebind group 0/1 correctly if needed.
2865
+ */
2866
+ private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, type: DrawCallType): void {
2867
+ let bound = false
1887
2868
  for (const draw of inst.drawCalls) {
1888
- if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1889
- pass.setBindGroup(2, draw.bindGroup)
1890
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2869
+ if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
2870
+ if (!bound) {
2871
+ pass.setPipeline(this.outlinePipeline)
2872
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup)
2873
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2874
+ bound = true
1891
2875
  }
2876
+ pass.setBindGroup(2, draw.bindGroup)
2877
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1892
2878
  }
1893
2879
  }
1894
2880
 
1895
- private bindMainGroups(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1896
- pass.setBindGroup(0, this.perFrameBindGroup)
1897
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
1898
- }
1899
-
2881
+ /**
2882
+ * Main-pass render sequence for one model instance:
2883
+ * 1) opaque bodies → 2) opaque outlines → 3) transparents → 4) transparent outlines.
2884
+ * Each batch binds the groups it needs, so switching between main and outline
2885
+ * pipelines is self-contained (no cross-batch dependencies).
2886
+ */
1900
2887
  private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1901
2888
  pass.setVertexBuffer(0, inst.vertexBuffer)
1902
2889
  pass.setVertexBuffer(1, inst.jointsBuffer)
1903
2890
  pass.setVertexBuffer(2, inst.weightsBuffer)
1904
2891
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
1905
2892
 
1906
- this.bindMainGroups(pass, inst)
1907
- this.drawOpaque(pass, inst, this.modelPipeline)
1908
- this.drawOutlines(pass, inst, false)
1909
- this.bindMainGroups(pass, inst)
1910
- this.drawTransparent(pass, inst, this.modelPipeline)
1911
- this.drawOutlines(pass, inst, true)
2893
+ this.drawMaterials(pass, inst, "opaque")
2894
+ this.drawOutlines(pass, inst, "opaque-outline")
2895
+ this.drawMaterials(pass, inst, "transparent")
2896
+ this.drawOutlines(pass, inst, "transparent-outline")
1912
2897
  }
1913
2898
 
1914
-
1915
2899
  private updateCameraUniforms() {
1916
2900
  const viewMatrix = this.camera.getViewMatrix()
1917
2901
  const projectionMatrix = this.camera.getProjectionMatrix()
@@ -1924,7 +2908,6 @@ export class Engine {
1924
2908
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
1925
2909
  }
1926
2910
 
1927
-
1928
2911
  private updateSkinMatrices() {
1929
2912
  this.forEachInstance((inst) => {
1930
2913
  const skinMatrices = inst.model.getSkinMatrices()
@@ -1933,24 +2916,11 @@ export class Engine {
1933
2916
  0,
1934
2917
  skinMatrices.buffer,
1935
2918
  skinMatrices.byteOffset,
1936
- skinMatrices.byteLength
2919
+ skinMatrices.byteLength,
1937
2920
  )
1938
2921
  })
1939
2922
  }
1940
2923
 
1941
- private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean) {
1942
- pass.setPipeline(this.outlinePipeline)
1943
- pass.setBindGroup(0, this.outlinePerFrameBindGroup)
1944
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
1945
- const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
1946
- for (const draw of inst.drawCalls) {
1947
- if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
1948
- pass.setBindGroup(2, draw.bindGroup)
1949
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1950
- }
1951
- }
1952
- }
1953
-
1954
2924
  private updateStats(frameTime: number) {
1955
2925
  // Simplified frame time tracking - rolling average with fixed window
1956
2926
  const maxSamples = 60
@@ -1975,5 +2945,4 @@ export class Engine {
1975
2945
  this.lastFpsUpdate = now
1976
2946
  }
1977
2947
  }
1978
-
1979
2948
  }