reze-engine 0.10.2 → 0.11.1

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 (62) hide show
  1. package/README.md +90 -13
  2. package/dist/engine.d.ts +177 -34
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +1204 -318
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/shaders/body.d.ts +2 -0
  9. package/dist/shaders/body.d.ts.map +1 -0
  10. package/dist/shaders/body.js +232 -0
  11. package/dist/shaders/classify.d.ts +4 -0
  12. package/dist/shaders/classify.d.ts.map +1 -0
  13. package/dist/shaders/classify.js +12 -0
  14. package/dist/shaders/cloth_rough.d.ts +2 -0
  15. package/dist/shaders/cloth_rough.d.ts.map +1 -0
  16. package/dist/shaders/cloth_rough.js +190 -0
  17. package/dist/shaders/cloth_smooth.d.ts +2 -0
  18. package/dist/shaders/cloth_smooth.d.ts.map +1 -0
  19. package/dist/shaders/cloth_smooth.js +186 -0
  20. package/dist/shaders/default.d.ts +2 -0
  21. package/dist/shaders/default.d.ts.map +1 -0
  22. package/dist/shaders/default.js +185 -0
  23. package/dist/shaders/dfg_lut.d.ts +3 -0
  24. package/dist/shaders/dfg_lut.d.ts.map +1 -0
  25. package/dist/shaders/dfg_lut.js +129 -0
  26. package/dist/shaders/eye.d.ts +2 -0
  27. package/dist/shaders/eye.d.ts.map +1 -0
  28. package/dist/shaders/eye.js +159 -0
  29. package/dist/shaders/face.d.ts +2 -0
  30. package/dist/shaders/face.d.ts.map +1 -0
  31. package/dist/shaders/face.js +235 -0
  32. package/dist/shaders/hair.d.ts +2 -0
  33. package/dist/shaders/hair.d.ts.map +1 -0
  34. package/dist/shaders/hair.js +196 -0
  35. package/dist/shaders/ltc_mag_lut.d.ts +3 -0
  36. package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
  37. package/dist/shaders/ltc_mag_lut.js +1033 -0
  38. package/dist/shaders/metal.d.ts +2 -0
  39. package/dist/shaders/metal.d.ts.map +1 -0
  40. package/dist/shaders/metal.js +187 -0
  41. package/dist/shaders/nodes.d.ts +2 -0
  42. package/dist/shaders/nodes.d.ts.map +1 -0
  43. package/dist/shaders/nodes.js +465 -0
  44. package/dist/shaders/stockings.d.ts +2 -0
  45. package/dist/shaders/stockings.d.ts.map +1 -0
  46. package/dist/shaders/stockings.js +244 -0
  47. package/package.json +1 -1
  48. package/src/engine.ts +1412 -385
  49. package/src/index.ts +12 -2
  50. package/src/shaders/body.ts +234 -0
  51. package/src/shaders/classify.ts +25 -0
  52. package/src/shaders/cloth_rough.ts +192 -0
  53. package/src/shaders/cloth_smooth.ts +188 -0
  54. package/src/shaders/default.ts +186 -0
  55. package/src/shaders/dfg_lut.ts +131 -0
  56. package/src/shaders/eye.ts +160 -0
  57. package/src/shaders/face.ts +237 -0
  58. package/src/shaders/hair.ts +198 -0
  59. package/src/shaders/ltc_mag_lut.ts +1035 -0
  60. package/src/shaders/metal.ts +189 -0
  61. package/src/shaders/nodes.ts +466 -0
  62. package/src/shaders/stockings.ts +246 -0
package/src/engine.ts CHANGED
@@ -13,6 +13,18 @@ import {
13
13
  normalizeAssetPath,
14
14
  type AssetReader,
15
15
  } from "./asset-reader"
16
+ import { DEFAULT_SHADER_WGSL } from "./shaders/default"
17
+ import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_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"
16
28
 
17
29
  export type RaycastCallback = (modelName: string, material: string | null, screenX: number, screenY: number) => void
18
30
 
@@ -22,30 +34,87 @@ export type LoadModelFromFilesOptions = {
22
34
  pmxFile?: File
23
35
  }
24
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.05,
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
+
25
100
  export type EngineOptions = {
26
- ambientColor?: Vec3
27
- directionalLightIntensity?: number
28
- minSpecularIntensity?: number
29
- rimLightIntensity?: number
30
- cameraDistance?: number
31
- cameraTarget?: Vec3
32
- 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>
33
108
  onRaycast?: RaycastCallback
34
109
  physicsOptions?: PhysicsOptions
35
- shadowLightDirection?: Vec3
36
110
  }
37
111
 
38
112
  export const DEFAULT_ENGINE_OPTIONS = {
39
- ambientColor: new Vec3(0.88, 0.88, 0.88),
40
- directionalLightIntensity: 0.24,
41
- minSpecularIntensity: 0.3,
42
- rimLightIntensity: 0.4,
43
- cameraDistance: 26.6,
44
- cameraTarget: new Vec3(0, 12.5, 0),
45
- 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 },
46
116
  onRaycast: undefined,
47
117
  physicsOptions: { constraintSolverKeywords: ["胸"] },
48
- shadowLightDirection: new Vec3(0.12, -1, 0.16),
49
118
  }
50
119
 
51
120
  export interface EngineStats {
@@ -53,12 +122,7 @@ export interface EngineStats {
53
122
  frameTime: number // ms
54
123
  }
55
124
 
56
- type DrawCallType =
57
- | "opaque"
58
- | "transparent"
59
- | "ground"
60
- | "opaque-outline"
61
- | "transparent-outline"
125
+ type DrawCallType = "opaque" | "transparent" | "ground" | "opaque-outline" | "transparent-outline"
62
126
 
63
127
  interface DrawCall {
64
128
  type: DrawCallType
@@ -66,6 +130,7 @@ interface DrawCall {
66
130
  firstIndex: number
67
131
  bindGroup: GPUBindGroup
68
132
  materialName: string
133
+ preset: MaterialPreset
69
134
  }
70
135
 
71
136
  interface PickDrawCall {
@@ -93,6 +158,7 @@ interface ModelInstance {
93
158
  pickPerInstanceBindGroup: GPUBindGroup
94
159
  pickDrawCalls: PickDrawCall[]
95
160
  hiddenMaterials: Set<string>
161
+ materialPresets: MaterialPresetMap | undefined
96
162
  physics: Physics | null
97
163
  vertexBufferNeedsUpdate: boolean
98
164
  }
@@ -114,15 +180,24 @@ export class Engine {
114
180
  private camera!: Camera
115
181
  private cameraUniformBuffer!: GPUBuffer
116
182
  private cameraMatrixData = new Float32Array(36)
117
- private cameraDistance!: number
118
- private cameraTarget!: Vec3
119
- 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 }
120
187
  private lightUniformBuffer!: GPUBuffer
121
188
  private lightData = new Float32Array(64)
122
189
  private lightCount = 0
123
190
  private resizeObserver: ResizeObserver | null = null
124
191
  private depthTexture!: GPUTexture
125
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
126
201
  private groundShadowPipeline!: GPURenderPipeline
127
202
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
128
203
  private outlinePipeline!: GPURenderPipeline
@@ -134,22 +209,63 @@ export class Engine {
134
209
  private perFrameBindGroup!: GPUBindGroup
135
210
  private outlinePerFrameBindGroup!: GPUBindGroup
136
211
  private multisampleTexture!: GPUTexture
212
+ private hdrResolveTexture!: GPUTexture
137
213
  private static readonly MULTISAMPLE_COUNT = 4
214
+ private static readonly HDR_FORMAT: GPUTextureFormat = "rgba16float"
215
+ /** Single-channel mask written alongside HDR color — 1 = model geometry (contributes
216
+ * to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
217
+ * prefilter so ground brightness can't halo the scene. */
218
+ private static readonly BLOOM_MASK_FORMAT: GPUTextureFormat = "r8unorm"
219
+ private multisampleMaskTexture!: GPUTexture
220
+ private maskResolveTexture!: GPUTexture
221
+ private maskResolveView!: GPUTextureView
138
222
  private renderPassDescriptor!: GPURenderPassDescriptor
139
-
140
- // Ambient light settings
141
- private ambientColor!: Vec3
142
- private directionalLightIntensity!: number
143
- private minSpecularIntensity!: number
144
- // Rim light settings
145
- private rimLightIntensity!: number
223
+ private compositePassDescriptor!: GPURenderPassDescriptor
224
+ private compositePipeline!: GPURenderPipeline
225
+ private compositeBindGroupLayout!: GPUBindGroupLayout
226
+ private compositeBindGroup!: GPUBindGroup
227
+ private compositeUniformBuffer!: GPUBuffer
228
+ // [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
229
+ private readonly compositeUniformData = new Float32Array(8)
230
+
231
+ // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
232
+ // blit (HDR → half-res, 4-tap Karis + soft threshold/knee)
233
+ // N-1 downsamples (13-tap Jimenez/COD box filter, 5 group averages)
234
+ // N-1 upsamples (9-tap tent, additively combined with corresponding downsample mip)
235
+ // composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
236
+ // Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
237
+ private bloomSampler!: GPUSampler
238
+ private bloomBlitUniformBuffer!: GPUBuffer
239
+ private bloomUpsampleUniformBuffer!: GPUBuffer
240
+ private readonly bloomBlitUniformData = new Float32Array(4)
241
+ private readonly bloomUpsampleUniformData = new Float32Array(4)
242
+ private bloomBlitPipeline!: GPURenderPipeline
243
+ private bloomDownsamplePipeline!: GPURenderPipeline
244
+ private bloomUpsamplePipeline!: GPURenderPipeline
245
+ private bloomBlitBindGroupLayout!: GPUBindGroupLayout
246
+ private bloomDownsampleBindGroupLayout!: GPUBindGroupLayout
247
+ private bloomUpsampleBindGroupLayout!: GPUBindGroupLayout
248
+ private bloomDownTexture!: GPUTexture
249
+ private bloomUpTexture!: GPUTexture
250
+ private bloomMipCount = 0
251
+ private bloomDownMipViews: GPUTextureView[] = []
252
+ private bloomUpMipViews: GPUTextureView[] = []
253
+ private bloomBlitBindGroup!: GPUBindGroup
254
+ private bloomDownsampleBindGroups: GPUBindGroup[] = []
255
+ private bloomUpsampleBindGroups: GPUBindGroup[] = []
256
+ /** Single-attachment pass; colorAttachments[0].view set per bloom step. */
257
+ private bloomPassDescriptor!: GPURenderPassDescriptor
258
+ private static readonly BLOOM_MAX_LEVELS = 7
146
259
 
147
260
  // Ground properties (shadow only)
148
261
  private groundVertexBuffer?: GPUBuffer
149
262
  private groundIndexBuffer?: GPUBuffer
150
263
  private hasGround = false
151
- private shadowMapTexture?: GPUTexture
152
- private shadowMapDepthView?: GPUTextureView
264
+ private shadowMapTexture!: GPUTexture
265
+ private shadowMapDepthView!: GPUTextureView
266
+ private brdfLutTexture!: GPUTexture
267
+ private brdfLutView!: GPUTextureView
268
+ private static readonly SHADOW_MAP_SIZE = 2048
153
269
  private shadowDepthPipeline!: GPURenderPipeline
154
270
  private shadowLightVPBuffer!: GPUBuffer
155
271
  private shadowLightVPMatrix = new Float32Array(16)
@@ -160,7 +276,6 @@ export class Engine {
160
276
 
161
277
  private onRaycast?: RaycastCallback
162
278
  private physicsOptions: PhysicsOptions = DEFAULT_ENGINE_OPTIONS.physicsOptions
163
- private shadowLightDirection: Vec3 = DEFAULT_ENGINE_OPTIONS.shadowLightDirection
164
279
  private lastTouchTime = 0
165
280
  private readonly DOUBLE_TAP_DELAY = 300
166
281
  // GPU picking
@@ -177,6 +292,8 @@ export class Engine {
177
292
  private modelInstances = new Map<string, ModelInstance>()
178
293
  private materialSampler!: GPUSampler
179
294
  private textureCache = new Map<string, GPUTexture>()
295
+ private mipBlitPipeline: GPURenderPipeline | null = null
296
+ private mipBlitSampler: GPUSampler | null = null
180
297
  private _nextDefaultModelId = 0
181
298
 
182
299
  // IK and physics enabled at engine level (same for all models)
@@ -199,24 +316,140 @@ export class Engine {
199
316
  }
200
317
  private animationFrameId: number | null = null
201
318
  private renderLoopCallback: (() => void) | null = null
319
+ private bloomSettings!: BloomOptions
320
+ private viewTransform!: ViewTransformOptions
202
321
 
203
322
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
204
323
  this.canvas = canvas
205
- if (options) {
206
- this.ambientColor = options.ambientColor ?? DEFAULT_ENGINE_OPTIONS.ambientColor
207
- this.directionalLightIntensity =
208
- options.directionalLightIntensity ?? DEFAULT_ENGINE_OPTIONS.directionalLightIntensity
209
- this.minSpecularIntensity = options.minSpecularIntensity ?? DEFAULT_ENGINE_OPTIONS.minSpecularIntensity
210
- this.rimLightIntensity = options.rimLightIntensity ?? DEFAULT_ENGINE_OPTIONS.rimLightIntensity
211
- this.cameraDistance = options.cameraDistance ?? DEFAULT_ENGINE_OPTIONS.cameraDistance
212
- this.cameraTarget = options.cameraTarget ?? DEFAULT_ENGINE_OPTIONS.cameraTarget
213
- this.cameraFov = options.cameraFov ?? DEFAULT_ENGINE_OPTIONS.cameraFov
214
- this.onRaycast = options.onRaycast
215
- this.physicsOptions = options.physicsOptions ?? DEFAULT_ENGINE_OPTIONS.physicsOptions
216
- this.shadowLightDirection = options.shadowLightDirection ?? DEFAULT_ENGINE_OPTIONS.shadowLightDirection
324
+ const d = DEFAULT_ENGINE_OPTIONS
325
+ this.world = {
326
+ color: options?.world?.color ?? d.world.color,
327
+ strength: options?.world?.strength ?? d.world.strength,
328
+ }
329
+ this.sun = {
330
+ color: options?.sun?.color ?? d.sun.color,
331
+ strength: options?.sun?.strength ?? d.sun.strength,
332
+ direction: options?.sun?.direction ?? d.sun.direction,
333
+ }
334
+ this.cameraConfig = {
335
+ distance: options?.camera?.distance ?? d.camera.distance,
336
+ target: options?.camera?.target ?? d.camera.target,
337
+ fov: options?.camera?.fov ?? d.camera.fov,
338
+ }
339
+ this.onRaycast = options?.onRaycast
340
+ this.physicsOptions = options?.physicsOptions ?? d.physicsOptions
341
+ this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom)
342
+ this.viewTransform = Engine.mergeViewTransformDefaults(options?.view)
343
+ }
344
+
345
+ /** Merge partial bloom with EEVEE defaults (same as constructor). */
346
+ static mergeBloomDefaults(partial?: Partial<BloomOptions>): BloomOptions {
347
+ const d = DEFAULT_BLOOM_OPTIONS
348
+ const c = partial?.color
349
+ return {
350
+ enabled: partial?.enabled ?? d.enabled,
351
+ threshold: partial?.threshold ?? d.threshold,
352
+ knee: partial?.knee ?? d.knee,
353
+ radius: partial?.radius ?? d.radius,
354
+ color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
355
+ intensity: partial?.intensity ?? d.intensity,
356
+ clamp: partial?.clamp ?? d.clamp,
357
+ }
358
+ }
359
+
360
+ static mergeViewTransformDefaults(partial?: Partial<ViewTransformOptions>): ViewTransformOptions {
361
+ const d = DEFAULT_VIEW_TRANSFORM
362
+ return {
363
+ exposure: partial?.exposure ?? d.exposure,
364
+ gamma: partial?.gamma ?? d.gamma,
365
+ look: partial?.look ?? d.look,
217
366
  }
218
367
  }
219
368
 
369
+ /** Current bloom settings (Blender names; tint is a copied `Vec3`). */
370
+ getBloomOptions(): BloomOptions {
371
+ const b = this.bloomSettings
372
+ return {
373
+ enabled: b.enabled,
374
+ threshold: b.threshold,
375
+ knee: b.knee,
376
+ radius: b.radius,
377
+ color: new Vec3(b.color.x, b.color.y, b.color.z),
378
+ intensity: b.intensity,
379
+ clamp: b.clamp,
380
+ }
381
+ }
382
+
383
+ getViewTransformOptions(): ViewTransformOptions {
384
+ const v = this.viewTransform
385
+ return { exposure: v.exposure, gamma: v.gamma, look: v.look }
386
+ }
387
+
388
+ setViewTransformOptions(patch: Partial<ViewTransformOptions>): void {
389
+ const v = this.viewTransform
390
+ if (patch.exposure !== undefined) v.exposure = patch.exposure
391
+ if (patch.gamma !== undefined) v.gamma = patch.gamma
392
+ if (patch.look !== undefined) v.look = patch.look
393
+ if (this.device && this.compositeUniformBuffer) {
394
+ this.writeCompositeViewUniforms()
395
+ }
396
+ }
397
+
398
+ private writeCompositeViewUniforms(): void {
399
+ const v = this.viewTransform
400
+ const b = this.bloomSettings
401
+ const effIntensity = b.enabled ? b.intensity : 0.0
402
+ const u = this.compositeUniformData
403
+ u[0] = v.exposure
404
+ u[1] = Math.max(v.gamma, 1e-4)
405
+ u[2] = 0.0
406
+ u[3] = 0.0
407
+ u[4] = b.color.x
408
+ u[5] = b.color.y
409
+ u[6] = b.color.z
410
+ u[7] = effIntensity
411
+ this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
412
+ }
413
+
414
+ /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
415
+ setBloomOptions(patch: Partial<BloomOptions>): void {
416
+ const b = this.bloomSettings
417
+ if (patch.enabled !== undefined) b.enabled = patch.enabled
418
+ if (patch.threshold !== undefined) b.threshold = patch.threshold
419
+ if (patch.knee !== undefined) b.knee = patch.knee
420
+ if (patch.radius !== undefined) b.radius = patch.radius
421
+ if (patch.color !== undefined) {
422
+ b.color.x = patch.color.x
423
+ b.color.y = patch.color.y
424
+ b.color.z = patch.color.z
425
+ }
426
+ if (patch.intensity !== undefined) b.intensity = patch.intensity
427
+ if (patch.clamp !== undefined) b.clamp = patch.clamp
428
+ if (this.device && this.bloomBlitUniformBuffer) {
429
+ this.writeBloomUniforms()
430
+ this.writeCompositeViewUniforms()
431
+ }
432
+ }
433
+
434
+ // EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
435
+ private writeBloomUniforms(): void {
436
+ const b = this.bloomSettings
437
+ const bu = this.bloomBlitUniformData
438
+ // EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
439
+ bu[0] = b.threshold
440
+ bu[1] = b.knee
441
+ bu[2] = b.clamp
442
+ bu[3] = 0.0
443
+ this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu)
444
+ const us = this.bloomUpsampleUniformData
445
+ // Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
446
+ us[0] = Math.max(0.5, b.radius)
447
+ us[1] = 0
448
+ us[2] = 0
449
+ us[3] = 0
450
+ this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us)
451
+ }
452
+
220
453
  // Step 1: Get WebGPU device and context
221
454
  async init() {
222
455
  const adapter = await navigator.gpu?.requestAdapter()
@@ -247,17 +480,100 @@ export class Engine {
247
480
  Engine.instance = this
248
481
  }
249
482
 
483
+ // One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
484
+ // with ltc_mag_ggx (eevee_lut.c) into a single 64×64 rgba8unorm texture:
485
+ // .rg = split-sum DFG → F_brdf_*_scatter
486
+ // .ba = LTC magnitude → ltc_brdf_scale_from_lut
487
+ // One texture fetch per fragment replaces the previous 2–3 taps. rgba8unorm
488
+ // (vs rgba16float) halves sample bandwidth; DFG/LTC values fit [0,1] cleanly.
489
+ private bakeBrdfLut() {
490
+ if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
491
+ throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).")
492
+ }
493
+
494
+ // Temp rg16float LTC source — loaded 1:1 by the bake fragment shader, then dropped.
495
+ const ltcTemp = this.device.createTexture({
496
+ label: "LTC mag LUT (bake input)",
497
+ size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
498
+ format: "rg16float",
499
+ usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
500
+ })
501
+ const n = LTC_MAG_LUT_DATA.length
502
+ const half = new Uint16Array(n)
503
+ const f32 = new Float32Array(1)
504
+ const u32 = new Uint32Array(f32.buffer)
505
+ for (let i = 0; i < n; i++) {
506
+ f32[0] = LTC_MAG_LUT_DATA[i]
507
+ const x = u32[0]
508
+ const sign = (x >>> 16) & 0x8000
509
+ let exp = ((x >>> 23) & 0xff) - 127 + 15
510
+ const mant = x & 0x7fffff
511
+ if (exp <= 0) {
512
+ half[i] = sign
513
+ } else if (exp >= 31) {
514
+ half[i] = sign | 0x7c00
515
+ } else {
516
+ half[i] = sign | (exp << 10) | (mant >>> 13)
517
+ }
518
+ }
519
+ this.device.queue.writeTexture(
520
+ { texture: ltcTemp },
521
+ half,
522
+ { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE },
523
+ { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 }
524
+ )
525
+
526
+ this.brdfLutTexture = this.device.createTexture({
527
+ label: "BRDF LUT (DFG + LTC packed)",
528
+ size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
529
+ format: "rgba8unorm",
530
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
531
+ })
532
+ this.brdfLutView = this.brdfLutTexture.createView()
533
+
534
+ const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL })
535
+ const pipeline = this.device.createRenderPipeline({
536
+ label: "BRDF LUT bake pipeline",
537
+ layout: "auto",
538
+ vertex: { module, entryPoint: "vs" },
539
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
540
+ primitive: { topology: "triangle-list" },
541
+ })
542
+
543
+ const bakeBindGroup = this.device.createBindGroup({
544
+ label: "BRDF LUT bake bind group",
545
+ layout: pipeline.getBindGroupLayout(0),
546
+ entries: [{ binding: 0, resource: ltcTemp.createView() }],
547
+ })
548
+
549
+ const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" })
550
+ const pass = enc.beginRenderPass({
551
+ colorAttachments: [
552
+ { view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
553
+ ],
554
+ })
555
+ pass.setPipeline(pipeline)
556
+ pass.setBindGroup(0, bakeBindGroup)
557
+ pass.draw(3, 1, 0, 0)
558
+ pass.end()
559
+ this.device.queue.submit([enc.finish()])
560
+
561
+ ltcTemp.destroy()
562
+ }
563
+
250
564
  private createRenderPipeline(config: {
251
565
  label: string
252
566
  layout: GPUPipelineLayout
253
567
  shaderModule: GPUShaderModule
254
568
  vertexBuffers: GPUVertexBufferLayout[]
255
569
  fragmentTarget?: GPUColorTargetState
570
+ fragmentTargets?: GPUColorTargetState[]
256
571
  fragmentEntryPoint?: string
257
572
  cullMode?: GPUCullMode
258
573
  depthStencil?: GPUDepthStencilState
259
574
  multisample?: GPUMultisampleState
260
575
  }): GPURenderPipeline {
576
+ const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
261
577
  return this.device.createRenderPipeline({
262
578
  label: config.label,
263
579
  layout: config.layout,
@@ -265,11 +581,11 @@ export class Engine {
265
581
  module: config.shaderModule,
266
582
  buffers: config.vertexBuffers,
267
583
  },
268
- fragment: config.fragmentTarget
584
+ fragment: targets
269
585
  ? {
270
586
  module: config.shaderModule,
271
587
  entryPoint: config.fragmentEntryPoint,
272
- targets: [config.fragmentTarget],
588
+ targets,
273
589
  }
274
590
  : undefined,
275
591
  primitive: { cullMode: config.cullMode ?? "none" },
@@ -282,6 +598,7 @@ export class Engine {
282
598
  this.materialSampler = this.device.createSampler({
283
599
  magFilter: "linear",
284
600
  minFilter: "linear",
601
+ mipmapFilter: "linear",
285
602
  addressModeU: "repeat",
286
603
  addressModeV: "repeat",
287
604
  })
@@ -324,8 +641,11 @@ export class Engine {
324
641
  },
325
642
  ]
326
643
 
644
+ // Internal scene passes render into the HDR offscreen target; only the final
645
+ // composite pass writes the swapchain. Tonemap moved to composite so bloom
646
+ // (added next) can run on linear HDR.
327
647
  const standardBlend: GPUColorTargetState = {
328
- format: this.presentationFormat,
648
+ format: Engine.HDR_FORMAT,
329
649
  blend: {
330
650
  color: {
331
651
  srcFactor: "src-alpha",
@@ -340,147 +660,74 @@ export class Engine {
340
660
  },
341
661
  }
342
662
 
343
- const shaderModule = this.device.createShaderModule({
344
- label: "model shaders",
345
- code: /* wgsl */ `
346
- struct CameraUniforms {
347
- view: mat4x4f,
348
- projection: mat4x4f,
349
- viewPos: vec3f,
350
- _padding: f32,
351
- };
352
-
353
- struct Light {
354
- direction: vec4f,
355
- color: vec4f,
356
- };
663
+ // Bloom mask target — r8unorm has no alpha channel, so src-alpha blending is invalid.
664
+ // Use replace mode: depth test already rejects occluded fragments, so last-writer-wins
665
+ // on surviving pixels gives the right result (ground writes 0; models/outlines write 1).
666
+ const maskBlend: GPUColorTargetState = { format: Engine.BLOOM_MASK_FORMAT }
667
+ const sceneTargets: GPUColorTargetState[] = [standardBlend, maskBlend]
357
668
 
358
- struct LightUniforms {
359
- ambientColor: vec4f,
360
- lights: array<Light, 4>,
361
- };
362
-
363
- struct MaterialUniforms {
364
- alpha: f32,
365
- rimIntensity: f32,
366
- shininess: f32,
367
- _padding1: f32,
368
- rimColor: vec3f,
369
- _padding2: f32,
370
- diffuseColor: vec3f,
371
- _padding3: f32,
372
- ambientColor: vec3f,
373
- _padding4: f32,
374
- specularColor: vec3f,
375
- _padding5: f32,
376
- };
377
-
378
- struct VertexOutput {
379
- @builtin(position) position: vec4f,
380
- @location(0) normal: vec3f,
381
- @location(1) uv: vec2f,
382
- @location(2) worldPos: vec3f,
383
- };
669
+ const shaderModule = this.device.createShaderModule({
670
+ label: "default model shader",
671
+ code: DEFAULT_SHADER_WGSL,
672
+ })
384
673
 
385
- // group 0: per-frame (bound once per pass)
386
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
387
- @group(0) @binding(1) var<uniform> light: LightUniforms;
388
- @group(0) @binding(2) var diffuseSampler: sampler;
389
- // group 1: per-instance (bound once per model)
390
- @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
391
- // group 2: per-material (bound per draw call)
392
- @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
393
- @group(2) @binding(1) var<uniform> material: MaterialUniforms;
674
+ const faceShaderModule = this.device.createShaderModule({
675
+ label: "face NPR shader",
676
+ code: FACE_SHADER_WGSL,
677
+ })
394
678
 
395
- @vertex fn vs(
396
- @location(0) position: vec3f,
397
- @location(1) normal: vec3f,
398
- @location(2) uv: vec2f,
399
- @location(3) joints0: vec4<u32>,
400
- @location(4) weights0: vec4<f32>
401
- ) -> VertexOutput {
402
- var output: VertexOutput;
403
- let pos4 = vec4f(position, 1.0);
404
-
405
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
406
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
407
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
408
-
409
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
410
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
411
- for (var i = 0u; i < 4u; i++) {
412
- let j = joints0[i];
413
- let w = normalizedWeights[i];
414
- let m = skinMats[j];
415
- skinnedPos += (m * pos4) * w;
416
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
417
- skinnedNrm += (r3 * normal) * w;
418
- }
419
- let worldPos = skinnedPos.xyz;
420
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
421
- output.normal = normalize(skinnedNrm);
422
- output.uv = uv;
423
- output.worldPos = worldPos;
424
- return output;
425
- }
679
+ const hairShaderModule = this.device.createShaderModule({
680
+ label: "hair NPR shader",
681
+ code: HAIR_SHADER_WGSL,
682
+ })
426
683
 
427
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
428
- let finalAlpha = material.alpha;
429
- if (finalAlpha < 0.001) {
430
- discard;
431
- }
432
-
433
- let n = normalize(input.normal);
434
- let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
684
+ const clothSmoothShaderModule = this.device.createShaderModule({
685
+ label: "cloth smooth NPR shader",
686
+ code: CLOTH_SMOOTH_SHADER_WGSL,
687
+ })
435
688
 
436
- let viewDir = normalize(camera.viewPos - input.worldPos);
689
+ const clothRoughShaderModule = this.device.createShaderModule({
690
+ label: "cloth rough NPR shader",
691
+ code: CLOTH_ROUGH_SHADER_WGSL,
692
+ })
437
693
 
438
- let albedo = textureColor * material.diffuseColor;
439
-
440
- let minSpec = light.ambientColor.w;
441
- let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
442
- let specPower = max(material.shininess, 1.0);
443
-
444
- let l = -light.lights[0].direction.xyz;
445
- let nDotL = max(dot(n, l), 0.0);
446
- let intensity = light.lights[0].color.w;
447
- let radiance = light.lights[0].color.xyz * intensity;
448
-
449
- let lightAccum = light.ambientColor.xyz + radiance * nDotL;
694
+ const metalShaderModule = this.device.createShaderModule({
695
+ label: "metal NPR shader",
696
+ code: METAL_SHADER_WGSL,
697
+ })
450
698
 
451
- let h = normalize(l + viewDir);
452
- let nDotH = max(dot(n, h), 0.0);
453
- let specFactor = pow(nDotH, specPower);
454
- let specularAccum = effectiveSpecular * radiance * specFactor * nDotL;
455
-
456
- let litColor = albedo * lightAccum;
699
+ const bodyShaderModule = this.device.createShaderModule({
700
+ label: "body NPR shader",
701
+ code: BODY_SHADER_WGSL,
702
+ })
457
703
 
458
- let fresnel = 1.0 - abs(dot(n, viewDir));
459
- let rimFactor = pow(fresnel, 4.0);
460
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
704
+ const eyeShaderModule = this.device.createShaderModule({
705
+ label: "eye shader",
706
+ code: EYE_SHADER_WGSL,
707
+ })
461
708
 
462
- let color = litColor + specularAccum + rimLight;
463
-
464
- return vec4f(color, finalAlpha);
465
- }
466
- `,
709
+ const stockingsShaderModule = this.device.createShaderModule({
710
+ label: "stockings NPR shader",
711
+ code: STOCKINGS_SHADER_WGSL,
467
712
  })
468
713
 
469
- // group 0: per-frame (camera + light + sampler) — bound once per pass
714
+ // group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
470
715
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
471
716
  label: "main per-frame bind group layout",
472
717
  entries: [
473
718
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
474
719
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
475
720
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
721
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
722
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
723
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
724
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
476
725
  ],
477
726
  })
478
727
  // group 1: per-instance (skinMats) — bound once per model
479
728
  this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
480
729
  label: "main per-instance bind group layout",
481
- entries: [
482
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
483
- ],
730
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
484
731
  })
485
732
  // group 2: per-material (texture + material uniforms) — bound per draw call
486
733
  this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
@@ -493,25 +740,133 @@ export class Engine {
493
740
 
494
741
  const mainPipelineLayout = this.device.createPipelineLayout({
495
742
  label: "main pipeline layout",
496
- bindGroupLayouts: [this.mainPerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.mainPerMaterialBindGroupLayout],
497
- })
498
-
499
- this.perFrameBindGroup = this.device.createBindGroup({
500
- label: "main per-frame bind group",
501
- layout: this.mainPerFrameBindGroupLayout,
502
- entries: [
503
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
504
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
505
- { binding: 2, resource: this.materialSampler },
743
+ bindGroupLayouts: [
744
+ this.mainPerFrameBindGroupLayout,
745
+ this.mainPerInstanceBindGroupLayout,
746
+ this.mainPerMaterialBindGroupLayout,
506
747
  ],
507
748
  })
508
749
 
750
+ // perFrameBindGroup is created after shadow resources below
751
+
509
752
  this.modelPipeline = this.createRenderPipeline({
510
753
  label: "model pipeline",
511
754
  layout: mainPipelineLayout,
512
755
  shaderModule,
513
756
  vertexBuffers: fullVertexBuffers,
514
- fragmentTarget: standardBlend,
757
+ fragmentTargets: sceneTargets,
758
+ cullMode: "none",
759
+ depthStencil: {
760
+ format: "depth24plus-stencil8",
761
+ depthWriteEnabled: true,
762
+ depthCompare: "less-equal",
763
+ },
764
+ })
765
+
766
+ this.facePipeline = this.createRenderPipeline({
767
+ label: "face NPR pipeline",
768
+ layout: mainPipelineLayout,
769
+ shaderModule: faceShaderModule,
770
+ vertexBuffers: fullVertexBuffers,
771
+ fragmentTargets: sceneTargets,
772
+ cullMode: "none",
773
+ depthStencil: {
774
+ format: "depth24plus-stencil8",
775
+ depthWriteEnabled: true,
776
+ depthCompare: "less-equal",
777
+ },
778
+ })
779
+
780
+ this.hairPipeline = this.createRenderPipeline({
781
+ label: "hair NPR pipeline",
782
+ layout: mainPipelineLayout,
783
+ shaderModule: hairShaderModule,
784
+ vertexBuffers: fullVertexBuffers,
785
+ fragmentTargets: sceneTargets,
786
+ cullMode: "none",
787
+ depthStencil: {
788
+ format: "depth24plus-stencil8",
789
+ depthWriteEnabled: true,
790
+ depthCompare: "less-equal",
791
+ },
792
+ })
793
+
794
+ this.clothSmoothPipeline = this.createRenderPipeline({
795
+ label: "cloth smooth NPR pipeline",
796
+ layout: mainPipelineLayout,
797
+ shaderModule: clothSmoothShaderModule,
798
+ vertexBuffers: fullVertexBuffers,
799
+ fragmentTargets: sceneTargets,
800
+ cullMode: "none",
801
+ depthStencil: {
802
+ format: "depth24plus-stencil8",
803
+ depthWriteEnabled: true,
804
+ depthCompare: "less-equal",
805
+ },
806
+ })
807
+
808
+ this.clothRoughPipeline = this.createRenderPipeline({
809
+ label: "cloth rough NPR pipeline",
810
+ layout: mainPipelineLayout,
811
+ shaderModule: clothRoughShaderModule,
812
+ vertexBuffers: fullVertexBuffers,
813
+ fragmentTargets: sceneTargets,
814
+ cullMode: "none",
815
+ depthStencil: {
816
+ format: "depth24plus-stencil8",
817
+ depthWriteEnabled: true,
818
+ depthCompare: "less-equal",
819
+ },
820
+ })
821
+
822
+ this.metalPipeline = this.createRenderPipeline({
823
+ label: "metal NPR pipeline",
824
+ layout: mainPipelineLayout,
825
+ shaderModule: metalShaderModule,
826
+ vertexBuffers: fullVertexBuffers,
827
+ fragmentTargets: sceneTargets,
828
+ cullMode: "none",
829
+ depthStencil: {
830
+ format: "depth24plus-stencil8",
831
+ depthWriteEnabled: true,
832
+ depthCompare: "less-equal",
833
+ },
834
+ })
835
+
836
+ this.bodyPipeline = this.createRenderPipeline({
837
+ label: "body NPR pipeline",
838
+ layout: mainPipelineLayout,
839
+ shaderModule: bodyShaderModule,
840
+ vertexBuffers: fullVertexBuffers,
841
+ fragmentTargets: sceneTargets,
842
+ cullMode: "none",
843
+ depthStencil: {
844
+ format: "depth24plus-stencil8",
845
+ depthWriteEnabled: true,
846
+ depthCompare: "less-equal",
847
+ },
848
+ })
849
+
850
+ this.eyePipeline = this.createRenderPipeline({
851
+ label: "eye pipeline",
852
+ layout: mainPipelineLayout,
853
+ shaderModule: eyeShaderModule,
854
+ vertexBuffers: fullVertexBuffers,
855
+ fragmentTargets: sceneTargets,
856
+ cullMode: "none",
857
+ depthStencil: {
858
+ format: "depth24plus-stencil8",
859
+ depthWriteEnabled: true,
860
+ depthCompare: "less-equal",
861
+ },
862
+ })
863
+
864
+ this.stockingsPipeline = this.createRenderPipeline({
865
+ label: "stockings NPR pipeline",
866
+ layout: mainPipelineLayout,
867
+ shaderModule: stockingsShaderModule,
868
+ vertexBuffers: fullVertexBuffers,
869
+ fragmentTargets: sceneTargets,
515
870
  cullMode: "none",
516
871
  depthStencil: {
517
872
  format: "depth24plus-stencil8",
@@ -568,6 +923,32 @@ export class Engine {
568
923
  magFilter: "linear",
569
924
  minFilter: "linear",
570
925
  })
926
+ this.shadowMapTexture = this.device.createTexture({
927
+ label: "shadow map",
928
+ size: [Engine.SHADOW_MAP_SIZE, Engine.SHADOW_MAP_SIZE],
929
+ format: "depth32float",
930
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
931
+ })
932
+ this.shadowMapDepthView = this.shadowMapTexture.createView()
933
+
934
+ // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
935
+ this.bakeBrdfLut()
936
+
937
+ // Now that shadow resources exist, create the main per-frame bind group
938
+ this.perFrameBindGroup = this.device.createBindGroup({
939
+ label: "main per-frame bind group",
940
+ layout: this.mainPerFrameBindGroupLayout,
941
+ entries: [
942
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
943
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
944
+ { binding: 2, resource: this.materialSampler },
945
+ { binding: 3, resource: this.shadowMapDepthView },
946
+ { binding: 4, resource: this.shadowComparisonSampler },
947
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
948
+ { binding: 9, resource: this.brdfLutView },
949
+ ],
950
+ })
951
+
571
952
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
572
953
  label: "ground shadow layout",
573
954
  entries: [
@@ -629,7 +1010,8 @@ export class Engine {
629
1010
  var o: VO; o.worldPos = position; o.normal = normal;
630
1011
  o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
631
1012
  }
632
- @fragment fn fs(i: VO) -> @location(0) vec4f {
1013
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
1014
+ @fragment fn fs(i: VO) -> FSOut {
633
1015
  let n = normalize(i.normal);
634
1016
  let centerDist = length(i.worldPos.xz);
635
1017
  let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));
@@ -667,7 +1049,10 @@ export class Engine {
667
1049
  var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
668
1050
  baseColor *= noiseTint;
669
1051
  let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
670
- return vec4f(finalColor * edgeFade, edgeFade);
1052
+ var out: FSOut;
1053
+ out.color = vec4f(finalColor * edgeFade, edgeFade);
1054
+ out.mask = 0.0;
1055
+ return out;
671
1056
  }
672
1057
  `,
673
1058
  })
@@ -676,7 +1061,7 @@ export class Engine {
676
1061
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
677
1062
  shaderModule: groundShadowShader,
678
1063
  vertexBuffers: fullVertexBuffers,
679
- fragmentTarget: standardBlend,
1064
+ fragmentTargets: sceneTargets,
680
1065
  cullMode: "back",
681
1066
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
682
1067
  })
@@ -698,15 +1083,17 @@ export class Engine {
698
1083
 
699
1084
  const outlinePipelineLayout = this.device.createPipelineLayout({
700
1085
  label: "outline pipeline layout",
701
- bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.outlinePerMaterialBindGroupLayout],
1086
+ bindGroupLayouts: [
1087
+ this.outlinePerFrameBindGroupLayout,
1088
+ this.mainPerInstanceBindGroupLayout,
1089
+ this.outlinePerMaterialBindGroupLayout,
1090
+ ],
702
1091
  })
703
1092
 
704
1093
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
705
1094
  label: "outline per-frame bind group",
706
1095
  layout: this.outlinePerFrameBindGroupLayout,
707
- entries: [
708
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
709
- ],
1096
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
710
1097
  })
711
1098
 
712
1099
  const outlineShaderModule = this.device.createShaderModule({
@@ -763,17 +1150,36 @@ export class Engine {
763
1150
  }
764
1151
  let worldPos = skinnedPos.xyz;
765
1152
  let worldNormal = normalize(skinnedNrm);
766
- // Screen-stable edgeline: extrusion ∝ camera distance (same idea as MMD viewers / babylon-mmd-style scaling)
767
- let camDist = max(length(camera.viewPos - worldPos), 0.25);
768
- let refDist = 30.0;
769
- let edgeScale = 0.025;
770
- let expandedPos = worldPos + worldNormal * material.edgeSize * edgeScale * (camDist / refDist);
771
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
1153
+
1154
+ // Screen-space outline extrusion — MMD-style pixel-stable edge line.
1155
+ // 1. Project position and normal-as-direction to clip space.
1156
+ // 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
1157
+ // matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
1158
+ // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
1159
+ // so the perspective divide cancels out → offset stays constant in NDC regardless
1160
+ // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
1161
+ // 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
1162
+ // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
1163
+ let viewProj = camera.projection * camera.view;
1164
+ let clipPos = viewProj * vec4f(worldPos, 1.0);
1165
+ let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
1166
+ // projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
1167
+ // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
1168
+ let aspect = camera.projection[1][1] / camera.projection[0][0];
1169
+ let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
1170
+ let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
1171
+ let edgeScale = 0.0016;
1172
+ let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
1173
+ output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
772
1174
  return output;
773
1175
  }
774
1176
 
775
- @fragment fn fs() -> @location(0) vec4f {
776
- return material.edgeColor;
1177
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
1178
+ @fragment fn fs() -> FSOut {
1179
+ var out: FSOut;
1180
+ out.color = material.edgeColor;
1181
+ out.mask = 1.0;
1182
+ return out;
777
1183
  }
778
1184
  `,
779
1185
  })
@@ -783,7 +1189,7 @@ export class Engine {
783
1189
  layout: outlinePipelineLayout,
784
1190
  shaderModule: outlineShaderModule,
785
1191
  vertexBuffers: outlineVertexBuffers,
786
- fragmentTarget: standardBlend,
1192
+ fragmentTargets: sceneTargets,
787
1193
  cullMode: "back",
788
1194
  depthStencil: {
789
1195
  format: "depth24plus-stencil8",
@@ -793,6 +1199,294 @@ export class Engine {
793
1199
  },
794
1200
  })
795
1201
 
1202
+ // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
1203
+ // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
1204
+ // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
1205
+ // Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
1206
+ this.bloomSampler = this.device.createSampler({
1207
+ label: "bloom sampler",
1208
+ magFilter: "linear",
1209
+ minFilter: "linear",
1210
+ addressModeU: "clamp-to-edge",
1211
+ addressModeV: "clamp-to-edge",
1212
+ })
1213
+ this.bloomBlitUniformBuffer = this.device.createBuffer({
1214
+ label: "bloom blit uniforms",
1215
+ size: 16,
1216
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1217
+ })
1218
+ this.bloomUpsampleUniformBuffer = this.device.createBuffer({
1219
+ label: "bloom upsample uniforms",
1220
+ size: 16,
1221
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1222
+ })
1223
+
1224
+ this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
1225
+ label: "bloom blit layout",
1226
+ entries: [
1227
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1228
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1229
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1230
+ ],
1231
+ })
1232
+ this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
1233
+ label: "bloom downsample layout",
1234
+ entries: [
1235
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1236
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1237
+ ],
1238
+ })
1239
+ this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
1240
+ label: "bloom upsample layout",
1241
+ entries: [
1242
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
1243
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
1244
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1245
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1246
+ ],
1247
+ })
1248
+
1249
+ const bloomFullscreenVs = /* wgsl */ `
1250
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1251
+ let x = f32((vi & 1u) << 2u) - 1.0;
1252
+ let y = f32((vi & 2u) << 1u) - 1.0;
1253
+ return vec4f(x, y, 0.0, 1.0);
1254
+ }
1255
+ `
1256
+
1257
+ // Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
1258
+ const bloomBlitShader = this.device.createShaderModule({
1259
+ label: "bloom blit (Karis prefilter)",
1260
+ code: `${bloomFullscreenVs}
1261
+ @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1262
+ @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
1263
+ @group(0) @binding(2) var maskTex: texture_2d<f32>;
1264
+
1265
+ fn luminance(c: vec3f) -> f32 {
1266
+ return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
1267
+ }
1268
+ fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
1269
+ let d = vec2<i32>(textureDimensions(hdrTex));
1270
+ let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
1271
+ let s = textureLoad(hdrTex, cc, 0);
1272
+ // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
1273
+ let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
1274
+ // Bloom mask: MRT r8unorm written by material shaders (1.0 = bloom, 0.0 = skip).
1275
+ let mask = textureLoad(maskTex, cc, 0).r;
1276
+ let masked = rgb * mask;
1277
+ // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
1278
+ return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
1279
+ }
1280
+
1281
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1282
+ let dst = vec2<i32>(p.xy - vec2f(0.5));
1283
+ let base = dst * 2;
1284
+ let clampV = prefilter.z;
1285
+ let a = fetch(base + vec2<i32>(0, 0), clampV);
1286
+ let b = fetch(base + vec2<i32>(1, 0), clampV);
1287
+ let c = fetch(base + vec2<i32>(0, 1), clampV);
1288
+ let d = fetch(base + vec2<i32>(1, 1), clampV);
1289
+ // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
1290
+ let wa = 1.0 / (1.0 + luminance(a));
1291
+ let wb = 1.0 / (1.0 + luminance(b));
1292
+ let wc = 1.0 / (1.0 + luminance(c));
1293
+ let wd = 1.0 / (1.0 + luminance(d));
1294
+ let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
1295
+ // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
1296
+ let bright = max(avg.r, max(avg.g, avg.b));
1297
+ let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
1298
+ let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
1299
+ let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
1300
+ return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
1301
+ }
1302
+ `,
1303
+ })
1304
+
1305
+ // Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
1306
+ const bloomDownsampleShader = this.device.createShaderModule({
1307
+ label: "bloom downsample 13-tap",
1308
+ code: `${bloomFullscreenVs}
1309
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
1310
+ @group(0) @binding(1) var srcSamp: sampler;
1311
+
1312
+ fn samp(uv: vec2f, off: vec2f) -> vec3f {
1313
+ return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
1314
+ }
1315
+
1316
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1317
+ let srcDims = vec2f(textureDimensions(srcTex));
1318
+ let t = 1.0 / srcDims;
1319
+ // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
1320
+ let dstDims = srcDims * 0.5;
1321
+ let uv = p.xy / max(dstDims, vec2f(1.0));
1322
+ let A = samp(uv, t * vec2f(-2.0, -2.0));
1323
+ let B = samp(uv, t * vec2f( 0.0, -2.0));
1324
+ let C = samp(uv, t * vec2f( 2.0, -2.0));
1325
+ let D = samp(uv, t * vec2f(-1.0, -1.0));
1326
+ let E = samp(uv, t * vec2f( 1.0, -1.0));
1327
+ let F = samp(uv, t * vec2f(-2.0, 0.0));
1328
+ let G = samp(uv, t * vec2f( 0.0, 0.0));
1329
+ let H = samp(uv, t * vec2f( 2.0, 0.0));
1330
+ let I = samp(uv, t * vec2f(-1.0, 1.0));
1331
+ let J = samp(uv, t * vec2f( 1.0, 1.0));
1332
+ let K = samp(uv, t * vec2f(-2.0, 2.0));
1333
+ let L = samp(uv, t * vec2f( 0.0, 2.0));
1334
+ let M = samp(uv, t * vec2f( 2.0, 2.0));
1335
+ var o = (D + E + I + J) * (0.5 / 4.0);
1336
+ o = o + (A + B + G + F) * (0.125 / 4.0);
1337
+ o = o + (B + C + H + G) * (0.125 / 4.0);
1338
+ o = o + (F + G + L + K) * (0.125 / 4.0);
1339
+ o = o + (G + H + M + L) * (0.125 / 4.0);
1340
+ return vec4f(o, 1.0);
1341
+ }
1342
+ `,
1343
+ })
1344
+
1345
+ // Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
1346
+ const bloomUpsampleShader = this.device.createShaderModule({
1347
+ label: "bloom upsample 9-tap tent",
1348
+ code: `${bloomFullscreenVs}
1349
+ @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
1350
+ @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
1351
+ @group(0) @binding(2) var srcSamp: sampler;
1352
+ @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
1353
+
1354
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1355
+ let srcDims = vec2f(textureDimensions(srcTex));
1356
+ let baseDims = vec2f(textureDimensions(baseTex));
1357
+ let uv = p.xy / max(baseDims, vec2f(1.0));
1358
+ let t = upU.x / srcDims;
1359
+ var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
1360
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
1361
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
1362
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
1363
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
1364
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
1365
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
1366
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
1367
+ o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
1368
+ o = o * (1.0 / 16.0);
1369
+ let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
1370
+ return vec4f(o + base, 1.0);
1371
+ }
1372
+ `,
1373
+ })
1374
+
1375
+ const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] })
1376
+ const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] })
1377
+ const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] })
1378
+
1379
+ this.bloomBlitPipeline = this.device.createRenderPipeline({
1380
+ label: "bloom blit pipeline",
1381
+ layout: bloomBlitLayout,
1382
+ vertex: { module: bloomBlitShader, entryPoint: "vs" },
1383
+ fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1384
+ primitive: { topology: "triangle-list" },
1385
+ })
1386
+ this.bloomDownsamplePipeline = this.device.createRenderPipeline({
1387
+ label: "bloom downsample pipeline",
1388
+ layout: bloomDownLayout,
1389
+ vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
1390
+ fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1391
+ primitive: { topology: "triangle-list" },
1392
+ })
1393
+ this.bloomUpsamplePipeline = this.device.createRenderPipeline({
1394
+ label: "bloom upsample pipeline",
1395
+ layout: bloomUpLayout,
1396
+ vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
1397
+ fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1398
+ primitive: { topology: "triangle-list" },
1399
+ })
1400
+
1401
+ // ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
1402
+ // Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
1403
+ // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1404
+ this.compositeUniformBuffer = this.device.createBuffer({
1405
+ label: "composite view uniforms",
1406
+ size: 32,
1407
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1408
+ })
1409
+ this.compositeBindGroupLayout = this.device.createBindGroupLayout({
1410
+ label: "composite bind group layout",
1411
+ entries: [
1412
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
1413
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1414
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1415
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1416
+ ],
1417
+ })
1418
+
1419
+ const compositeShader = this.device.createShaderModule({
1420
+ label: "composite shader",
1421
+ code: /* wgsl */ `
1422
+ @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1423
+ @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
1424
+ @group(0) @binding(2) var bloomSamp: sampler;
1425
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
1426
+ // viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
1427
+
1428
+ fn filmic(x: f32) -> f32 {
1429
+ var lut = array<f32, 14>(
1430
+ 0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
1431
+ 0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
1432
+ );
1433
+ let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
1434
+ let i = u32(t);
1435
+ let j = min(i + 1u, 13u);
1436
+ return mix(lut[i], lut[j], t - f32(i));
1437
+ }
1438
+
1439
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1440
+ let x = f32((vi & 1u) << 2u) - 1.0;
1441
+ let y = f32((vi & 2u) << 1u) - 1.0;
1442
+ return vec4f(x, y, 0.0, 1.0);
1443
+ }
1444
+
1445
+ @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
1446
+ let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
1447
+ let a = max(hdr.a, 1e-6);
1448
+ let straight = hdr.rgb / a;
1449
+ let fullSz = vec2f(textureDimensions(hdrTex));
1450
+ let bloomSz = vec2f(textureDimensions(bloomTex));
1451
+ // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
1452
+ let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
1453
+ let tint = viewU[1].xyz;
1454
+ let intensity = viewU[1].w;
1455
+ let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
1456
+ let combined = straight + bloom;
1457
+ let exposed = combined * exp2(viewU[0].x);
1458
+ let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
1459
+ let g = max(viewU[0].y, 1e-4);
1460
+ let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
1461
+ return vec4f(disp * hdr.a, hdr.a);
1462
+ }
1463
+ `,
1464
+ })
1465
+
1466
+ this.compositePipeline = this.device.createRenderPipeline({
1467
+ label: "composite pipeline",
1468
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
1469
+ vertex: { module: compositeShader, entryPoint: "vs" },
1470
+ fragment: {
1471
+ module: compositeShader,
1472
+ entryPoint: "fs",
1473
+ targets: [{ format: this.presentationFormat }],
1474
+ },
1475
+ primitive: { topology: "triangle-list" },
1476
+ })
1477
+
1478
+ this.bloomPassDescriptor = {
1479
+ label: "bloom pass",
1480
+ colorAttachments: [
1481
+ {
1482
+ view: undefined as unknown as GPUTextureView,
1483
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1484
+ loadOp: "clear",
1485
+ storeOp: "store",
1486
+ },
1487
+ ],
1488
+ } as GPURenderPassDescriptor
1489
+
796
1490
  // GPU picking: encode (modelIndex, materialIndex) as color
797
1491
  const pickShaderModule = this.device.createShaderModule({
798
1492
  label: "pick shader",
@@ -838,34 +1532,30 @@ export class Engine {
838
1532
 
839
1533
  this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
840
1534
  label: "pick per-frame layout",
841
- entries: [
842
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
843
- ],
1535
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
844
1536
  })
845
1537
  this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
846
1538
  label: "pick per-instance layout",
847
- entries: [
848
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
849
- ],
1539
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
850
1540
  })
851
1541
  this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
852
1542
  label: "pick per-material layout",
853
- entries: [
854
- { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
855
- ],
1543
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
856
1544
  })
857
1545
 
858
1546
  const pickPipelineLayout = this.device.createPipelineLayout({
859
1547
  label: "pick pipeline layout",
860
- bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
1548
+ bindGroupLayouts: [
1549
+ this.pickPerFrameBindGroupLayout,
1550
+ this.pickPerInstanceBindGroupLayout,
1551
+ this.pickPerMaterialBindGroupLayout,
1552
+ ],
861
1553
  })
862
1554
 
863
1555
  this.pickPerFrameBindGroup = this.device.createBindGroup({
864
1556
  label: "pick per-frame bind group",
865
1557
  layout: this.pickPerFrameBindGroupLayout,
866
- entries: [
867
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
868
- ],
1558
+ entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
869
1559
  })
870
1560
 
871
1561
  this.pickPipeline = this.device.createRenderPipeline({
@@ -891,7 +1581,6 @@ export class Engine {
891
1581
  })
892
1582
  }
893
1583
 
894
-
895
1584
  // Step 3: Setup canvas resize handling
896
1585
  private setupResize() {
897
1586
  this.resizeObserver = new ResizeObserver(() => this.handleResize())
@@ -918,13 +1607,74 @@ export class Engine {
918
1607
  this.canvas.height = height
919
1608
 
920
1609
  this.multisampleTexture = this.device.createTexture({
921
- label: "multisample render target",
1610
+ label: "multisample HDR render target",
922
1611
  size: [width, height],
923
1612
  sampleCount: Engine.MULTISAMPLE_COUNT,
924
- format: this.presentationFormat,
1613
+ format: Engine.HDR_FORMAT,
925
1614
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
926
1615
  })
927
1616
 
1617
+ this.hdrResolveTexture = this.device.createTexture({
1618
+ label: "HDR resolve target",
1619
+ size: [width, height],
1620
+ format: Engine.HDR_FORMAT,
1621
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1622
+ })
1623
+
1624
+ // Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
1625
+ // MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
1626
+ this.multisampleMaskTexture = this.device.createTexture({
1627
+ label: "multisample bloom mask",
1628
+ size: [width, height],
1629
+ sampleCount: Engine.MULTISAMPLE_COUNT,
1630
+ format: Engine.BLOOM_MASK_FORMAT,
1631
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
1632
+ })
1633
+ this.maskResolveTexture = this.device.createTexture({
1634
+ label: "bloom mask resolve",
1635
+ size: [width, height],
1636
+ format: Engine.BLOOM_MASK_FORMAT,
1637
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1638
+ })
1639
+ this.maskResolveView = this.maskResolveTexture.createView()
1640
+
1641
+ // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
1642
+ // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
1643
+ const bw = Math.max(1, Math.floor(width / 2))
1644
+ const bh = Math.max(1, Math.floor(height / 2))
1645
+ const shortSide = Math.max(1, Math.min(bw, bh))
1646
+ this.bloomMipCount = Math.max(
1647
+ 1,
1648
+ Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1),
1649
+ )
1650
+ this.bloomDownTexture = this.device.createTexture({
1651
+ label: "bloom down pyramid",
1652
+ size: [bw, bh],
1653
+ mipLevelCount: this.bloomMipCount,
1654
+ format: Engine.HDR_FORMAT,
1655
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1656
+ })
1657
+ this.bloomUpTexture = this.device.createTexture({
1658
+ label: "bloom up pyramid",
1659
+ size: [bw, bh],
1660
+ mipLevelCount: Math.max(1, this.bloomMipCount - 1),
1661
+ format: Engine.HDR_FORMAT,
1662
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1663
+ })
1664
+ this.bloomDownMipViews = []
1665
+ for (let i = 0; i < this.bloomMipCount; i++) {
1666
+ this.bloomDownMipViews.push(
1667
+ this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }),
1668
+ )
1669
+ }
1670
+ this.bloomUpMipViews = []
1671
+ const upLevels = Math.max(1, this.bloomMipCount - 1)
1672
+ for (let i = 0; i < upLevels; i++) {
1673
+ this.bloomUpMipViews.push(
1674
+ this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }),
1675
+ )
1676
+ }
1677
+
928
1678
  this.depthTexture = this.device.createTexture({
929
1679
  label: "depth texture",
930
1680
  size: [width, height],
@@ -937,7 +1687,15 @@ export class Engine {
937
1687
 
938
1688
  const colorAttachment: GPURenderPassColorAttachment = {
939
1689
  view: this.multisampleTexture.createView(),
940
- resolveTarget: this.context.getCurrentTexture().createView(),
1690
+ resolveTarget: this.hdrResolveTexture.createView(),
1691
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1692
+ loadOp: "clear",
1693
+ storeOp: "store",
1694
+ }
1695
+
1696
+ const maskAttachment: GPURenderPassColorAttachment = {
1697
+ view: this.multisampleMaskTexture.createView(),
1698
+ resolveTarget: this.maskResolveView,
941
1699
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
942
1700
  loadOp: "clear",
943
1701
  storeOp: "store",
@@ -945,7 +1703,7 @@ export class Engine {
945
1703
 
946
1704
  this.renderPassDescriptor = {
947
1705
  label: "renderPass",
948
- colorAttachments: [colorAttachment],
1706
+ colorAttachments: [colorAttachment, maskAttachment],
949
1707
  depthStencilAttachment: {
950
1708
  view: depthTextureView,
951
1709
  depthClearValue: 1.0,
@@ -957,6 +1715,81 @@ export class Engine {
957
1715
  },
958
1716
  }
959
1717
 
1718
+ // Composite pass descriptor (color attachment view patched per-frame to current swapchain).
1719
+ this.compositePassDescriptor = {
1720
+ label: "composite pass",
1721
+ colorAttachments: [
1722
+ {
1723
+ view: undefined as unknown as GPUTextureView,
1724
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1725
+ loadOp: "clear",
1726
+ storeOp: "store",
1727
+ },
1728
+ ],
1729
+ }
1730
+
1731
+ this.writeBloomUniforms()
1732
+
1733
+ if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
1734
+ // Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
1735
+ this.bloomBlitBindGroup = this.device.createBindGroup({
1736
+ label: "bloom blit bind group",
1737
+ layout: this.bloomBlitBindGroupLayout,
1738
+ entries: [
1739
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
1740
+ { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
1741
+ { binding: 2, resource: this.maskResolveView },
1742
+ ],
1743
+ })
1744
+ // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
1745
+ this.bloomDownsampleBindGroups = []
1746
+ for (let i = 1; i < this.bloomMipCount; i++) {
1747
+ this.bloomDownsampleBindGroups.push(
1748
+ this.device.createBindGroup({
1749
+ label: `bloom downsample ${i}`,
1750
+ layout: this.bloomDownsampleBindGroupLayout,
1751
+ entries: [
1752
+ { binding: 0, resource: this.bloomDownMipViews[i - 1] },
1753
+ { binding: 1, resource: this.bloomSampler },
1754
+ ],
1755
+ }),
1756
+ )
1757
+ }
1758
+ // Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
1759
+ // subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
1760
+ this.bloomUpsampleBindGroups = []
1761
+ const topIdx = this.bloomMipCount - 2
1762
+ for (let i = topIdx; i >= 0; i--) {
1763
+ const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1]
1764
+ this.bloomUpsampleBindGroups.push(
1765
+ this.device.createBindGroup({
1766
+ label: `bloom upsample ${i}`,
1767
+ layout: this.bloomUpsampleBindGroupLayout,
1768
+ entries: [
1769
+ { binding: 0, resource: srcView },
1770
+ { binding: 1, resource: this.bloomDownMipViews[i] },
1771
+ { binding: 2, resource: this.bloomSampler },
1772
+ { binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
1773
+ ],
1774
+ }),
1775
+ )
1776
+ }
1777
+ // Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
1778
+ const compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0]
1779
+ this.compositeBindGroup = this.device.createBindGroup({
1780
+ label: "composite bind group",
1781
+ layout: this.compositeBindGroupLayout,
1782
+ entries: [
1783
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
1784
+ { binding: 1, resource: compositeBloomView },
1785
+ { binding: 2, resource: this.bloomSampler },
1786
+ { binding: 3, resource: { buffer: this.compositeUniformBuffer } },
1787
+ ],
1788
+ })
1789
+ }
1790
+
1791
+ this.writeCompositeViewUniforms()
1792
+
960
1793
  this.camera.aspect = width / height
961
1794
 
962
1795
  if (this.onRaycast) {
@@ -984,7 +1817,13 @@ export class Engine {
984
1817
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
985
1818
  })
986
1819
 
987
- this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraDistance, this.cameraTarget, this.cameraFov)
1820
+ this.camera = new Camera(
1821
+ Math.PI,
1822
+ Math.PI / 2.5,
1823
+ this.cameraConfig.distance,
1824
+ this.cameraConfig.target,
1825
+ this.cameraConfig.fov,
1826
+ )
988
1827
 
989
1828
  this.camera.aspect = this.canvas.width / this.canvas.height
990
1829
  this.camera.attachControl(this.canvas)
@@ -1026,55 +1865,92 @@ export class Engine {
1026
1865
  this.cameraTargetOffset.z = offset?.z ?? 0
1027
1866
  }
1028
1867
 
1029
- getCameraDistance(): number { return this.camera.radius }
1030
- setCameraDistance(d: number): void { this.camera.radius = d }
1031
- getCameraAlpha(): number { return this.camera.alpha }
1032
- setCameraAlpha(a: number): void { this.camera.alpha = a }
1033
- getCameraBeta(): number { return this.camera.beta }
1034
- setCameraBeta(b: number): void { this.camera.beta = b }
1868
+ getCameraDistance(): number {
1869
+ return this.camera.radius
1870
+ }
1871
+ setCameraDistance(d: number): void {
1872
+ this.camera.radius = d
1873
+ }
1874
+ getCameraAlpha(): number {
1875
+ return this.camera.alpha
1876
+ }
1877
+ setCameraAlpha(a: number): void {
1878
+ this.camera.alpha = a
1879
+ }
1880
+ getCameraBeta(): number {
1881
+ return this.camera.beta
1882
+ }
1883
+ setCameraBeta(b: number): void {
1884
+ this.camera.beta = b
1885
+ }
1035
1886
 
1036
1887
  // Step 5: Create lighting buffers
1037
1888
  private setupLighting() {
1038
1889
  this.lightUniformBuffer = this.device.createBuffer({
1039
1890
  label: "light uniforms",
1040
- size: 64 * 4, // 64 floats: ambientColor vec4f (4) + 4 lights * 2 vec4f each (32)
1891
+ size: 64 * 4, // ambientColor vec4f (4) + 4 lights * 2 vec4f each (32) = 36 f32 padded to 64
1041
1892
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1042
1893
  })
1043
-
1044
- // Initialize light buffer to zeros
1045
1894
  this.lightData.fill(0)
1046
1895
  this.lightCount = 0
1896
+ this.writeWorld()
1897
+ this.writeSun(0)
1898
+ }
1047
1899
 
1048
- this.setAmbientColor(this.ambientColor)
1049
- this.addLight(new Vec3(0.5, -1, 1).normalize(), new Vec3(1.0, 1.0, 1.0), this.directionalLightIntensity)
1900
+ /**
1901
+ * Write world ambient. For a uniform-radiance world, hemispherical irradiance
1902
+ * is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
1903
+ * shader's ambient uniform is just `world.color × world.strength` — no /π.
1904
+ */
1905
+ private writeWorld() {
1906
+ const s = this.world.strength
1907
+ this.lightData[0] = this.world.color.x * s
1908
+ this.lightData[1] = this.world.color.y * s
1909
+ this.lightData[2] = this.world.color.z * s
1910
+ this.lightData[3] = 0
1911
+ this.updateLightBuffer()
1050
1912
  }
1051
1913
 
1052
- private setAmbientColor(color: Vec3) {
1053
- // Layout: ambientColor (0-3), lights (4-63) - 2 vec4f per light
1054
- this.lightData[0] = color.x // ambientColor.x
1055
- this.lightData[1] = color.y // ambientColor.y
1056
- this.lightData[2] = color.z // ambientColor.z
1057
- this.lightData[3] = this.minSpecularIntensity // ambientColor.w = minSpecularIntensity
1914
+ /** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
1915
+ private writeSun(index: number) {
1916
+ if (index < 0 || index >= 4) return
1917
+ const normalized = this.sun.direction.normalize()
1918
+ const base = 4 + index * 8 // 8 floats per light (direction vec4, color vec4)
1919
+ this.lightData[base] = normalized.x
1920
+ this.lightData[base + 1] = normalized.y
1921
+ this.lightData[base + 2] = normalized.z
1922
+ this.lightData[base + 3] = 0
1923
+ this.lightData[base + 4] = this.sun.color.x
1924
+ this.lightData[base + 5] = this.sun.color.y
1925
+ this.lightData[base + 6] = this.sun.color.z
1926
+ this.lightData[base + 7] = this.sun.strength
1927
+ if (index >= this.lightCount) this.lightCount = index + 1
1058
1928
  this.updateLightBuffer()
1059
1929
  }
1060
1930
 
1061
- private addLight(direction: Vec3, color: Vec3, intensity: number = 1.0): boolean {
1062
- if (this.lightCount >= 4) return false
1931
+ /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
1932
+ setWorld(options: WorldOptions): void {
1933
+ if (options.color) this.world.color = options.color
1934
+ if (options.strength !== undefined) this.world.strength = options.strength
1935
+ this.writeWorld()
1936
+ }
1063
1937
 
1064
- const normalized = direction.normalize()
1065
- const baseIndex = 4 + this.lightCount * 8 // Start at index 4, 8 floats per light (2 vec4f)
1066
- this.lightData[baseIndex] = normalized.x // direction.x
1067
- this.lightData[baseIndex + 1] = normalized.y // direction.y
1068
- this.lightData[baseIndex + 2] = normalized.z // direction.z
1069
- this.lightData[baseIndex + 3] = 0 // direction.w
1070
- this.lightData[baseIndex + 4] = color.x // color.x
1071
- this.lightData[baseIndex + 5] = color.y // color.y
1072
- this.lightData[baseIndex + 6] = color.z // color.z
1073
- this.lightData[baseIndex + 7] = intensity // color.w / intensity
1938
+ /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
1939
+ setSun(options: SunOptions): void {
1940
+ if (options.color) this.sun.color = options.color
1941
+ if (options.strength !== undefined) this.sun.strength = options.strength
1942
+ if (options.direction) {
1943
+ this.sun.direction = options.direction
1944
+ this.shadowLightVPDirty = true
1945
+ }
1946
+ this.writeSun(0)
1947
+ }
1074
1948
 
1075
- this.lightCount++
1076
- this.updateLightBuffer()
1077
- return true
1949
+ getWorld(): Readonly<{ color: Vec3; strength: number }> {
1950
+ return this.world
1951
+ }
1952
+ getSun(): Readonly<{ color: Vec3; strength: number; direction: Vec3 }> {
1953
+ return this.sun
1078
1954
  }
1079
1955
 
1080
1956
  addGround(options?: {
@@ -1083,7 +1959,6 @@ export class Engine {
1083
1959
  diffuseColor?: Vec3
1084
1960
  fadeStart?: number
1085
1961
  fadeEnd?: number
1086
- shadowMapSize?: number
1087
1962
  shadowStrength?: number
1088
1963
  gridSpacing?: number
1089
1964
  gridLineWidth?: number
@@ -1094,10 +1969,9 @@ export class Engine {
1094
1969
  const opts = {
1095
1970
  width: 160,
1096
1971
  height: 160,
1097
- diffuseColor: new Vec3(0.8, 0.1, 1.0),
1972
+ diffuseColor: new Vec3(0.9, 0.1, 1.0),
1098
1973
  fadeStart: 10.0,
1099
1974
  fadeEnd: 80.0,
1100
- shadowMapSize: 4096,
1101
1975
  shadowStrength: 1.0,
1102
1976
  gridSpacing: 4.2,
1103
1977
  gridLineWidth: 0.012,
@@ -1115,6 +1989,7 @@ export class Engine {
1115
1989
  firstIndex: 0,
1116
1990
  bindGroup: this.groundShadowBindGroup!,
1117
1991
  materialName: "Ground",
1992
+ preset: "cloth_rough",
1118
1993
  }
1119
1994
  }
1120
1995
 
@@ -1171,17 +2046,14 @@ export class Engine {
1171
2046
  async loadModel(path: string): Promise<Model>
1172
2047
  async loadModel(name: string, path: string): Promise<Model>
1173
2048
  async loadModel(name: string, options: LoadModelFromFilesOptions): Promise<Model>
1174
- async loadModel(
1175
- nameOrPath: string,
1176
- pathOrOptions?: string | LoadModelFromFilesOptions
1177
- ): Promise<Model> {
2049
+ async loadModel(nameOrPath: string, pathOrOptions?: string | LoadModelFromFilesOptions): Promise<Model> {
1178
2050
  if (pathOrOptions !== undefined && typeof pathOrOptions === "object" && "files" in pathOrOptions) {
1179
2051
  const name = nameOrPath
1180
2052
  const pmxFile = pathOrOptions.pmxFile ?? findFirstPmxFileInList(pathOrOptions.files)
1181
2053
  if (!pmxFile) throw new Error("No .pmx file found in the selected folder")
1182
2054
  const map = fileListToMap(pathOrOptions.files)
1183
2055
  const pmxKey = normalizeAssetPath(
1184
- (pmxFile as File & { webkitRelativePath?: string }).webkitRelativePath ?? pmxFile.name
2056
+ (pmxFile as File & { webkitRelativePath?: string }).webkitRelativePath ?? pmxFile.name,
1185
2057
  )
1186
2058
  const reader = createFileMapAssetReader(map)
1187
2059
  const model = await PmxLoader.loadFromReader(reader, pmxKey)
@@ -1252,6 +2124,15 @@ export class Engine {
1252
2124
  }
1253
2125
  }
1254
2126
 
2127
+ setMaterialPresets(modelName: string, presets: MaterialPresetMap): void {
2128
+ const inst = this.modelInstances.get(modelName)
2129
+ if (!inst) return
2130
+ inst.materialPresets = presets
2131
+ for (const dc of inst.drawCalls) {
2132
+ dc.preset = resolvePreset(dc.materialName, presets)
2133
+ }
2134
+ }
2135
+
1255
2136
  setMaterialVisible(modelName: string, materialName: string, visible: boolean): void {
1256
2137
  const inst = this.modelInstances.get(modelName)
1257
2138
  if (!inst) return
@@ -1296,11 +2177,7 @@ export class Engine {
1296
2177
  const verticesChanged = inst.model.update(deltaTime, this.ikEnabled)
1297
2178
  if (verticesChanged) inst.vertexBufferNeedsUpdate = true
1298
2179
  if (inst.physics && this.physicsEnabled) {
1299
- inst.physics.step(
1300
- deltaTime,
1301
- inst.model.getWorldMatrices(),
1302
- inst.model.getBoneInverseBindMatrices()
1303
- )
2180
+ inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices())
1304
2181
  }
1305
2182
  if (inst.vertexBufferNeedsUpdate) this.updateVertexBuffer(inst)
1306
2183
  })
@@ -1313,7 +2190,12 @@ export class Engine {
1313
2190
  inst.vertexBufferNeedsUpdate = false
1314
2191
  }
1315
2192
 
1316
- private async setupModelInstance(name: string, model: Model, basePath: string, assetReader: AssetReader): Promise<void> {
2193
+ private async setupModelInstance(
2194
+ name: string,
2195
+ model: Model,
2196
+ basePath: string,
2197
+ assetReader: AssetReader,
2198
+ ): Promise<void> {
1317
2199
  const vertices = model.getVertices()
1318
2200
  const skinning = model.getSkinning()
1319
2201
  const skeleton = model.getSkeleton()
@@ -1337,7 +2219,7 @@ export class Engine {
1337
2219
  0,
1338
2220
  skinning.joints.buffer,
1339
2221
  skinning.joints.byteOffset,
1340
- skinning.joints.byteLength
2222
+ skinning.joints.byteLength,
1341
2223
  )
1342
2224
 
1343
2225
  const weightsBuffer = this.device.createBuffer({
@@ -1350,7 +2232,7 @@ export class Engine {
1350
2232
  0,
1351
2233
  skinning.weights.buffer,
1352
2234
  skinning.weights.byteOffset,
1353
- skinning.weights.byteLength
2235
+ skinning.weights.byteLength,
1354
2236
  )
1355
2237
 
1356
2238
  const skinMatrixBuffer = this.device.createBuffer({
@@ -1383,26 +2265,16 @@ export class Engine {
1383
2265
  const mainPerInstanceBindGroup = this.device.createBindGroup({
1384
2266
  label: `${name}: main per-instance bind group`,
1385
2267
  layout: this.mainPerInstanceBindGroupLayout,
1386
- entries: [
1387
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1388
- ],
2268
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1389
2269
  })
1390
2270
 
1391
2271
  const pickPerInstanceBindGroup = this.device.createBindGroup({
1392
2272
  label: `${name}: pick per-instance bind group`,
1393
2273
  layout: this.pickPerInstanceBindGroupLayout,
1394
- entries: [
1395
- { binding: 0, resource: { buffer: skinMatrixBuffer } },
1396
- ],
2274
+ entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
1397
2275
  })
1398
2276
 
1399
- const gpuBuffers: GPUBuffer[] = [
1400
- vertexBuffer,
1401
- indexBuffer,
1402
- jointsBuffer,
1403
- weightsBuffer,
1404
- skinMatrixBuffer,
1405
- ]
2277
+ const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer]
1406
2278
 
1407
2279
  const inst: ModelInstance = {
1408
2280
  name,
@@ -1423,6 +2295,7 @@ export class Engine {
1423
2295
  pickPerInstanceBindGroup,
1424
2296
  pickDrawCalls: [],
1425
2297
  hiddenMaterials: new Set(),
2298
+ materialPresets: undefined,
1426
2299
  physics,
1427
2300
  vertexBufferNeedsUpdate: false,
1428
2301
  }
@@ -1503,7 +2376,6 @@ export class Engine {
1503
2376
  }
1504
2377
 
1505
2378
  private createShadowGroundResources(opts: {
1506
- shadowMapSize: number
1507
2379
  diffuseColor: Vec3
1508
2380
  fadeStart: number
1509
2381
  fadeEnd: number
@@ -1514,21 +2386,39 @@ export class Engine {
1514
2386
  gridLineColor: Vec3
1515
2387
  noiseStrength: number
1516
2388
  }) {
1517
- const { shadowMapSize, diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength } = opts
1518
- this.shadowMapTexture = this.device.createTexture({
1519
- label: "shadow map",
1520
- size: [shadowMapSize, shadowMapSize],
1521
- format: "depth32float",
1522
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1523
- })
1524
- this.shadowMapDepthView = this.shadowMapTexture.createView()
1525
- // 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)
2389
+ const {
2390
+ diffuseColor,
2391
+ fadeStart,
2392
+ fadeEnd,
2393
+ shadowStrength,
2394
+ gridSpacing,
2395
+ gridLineWidth,
2396
+ gridLineOpacity,
2397
+ gridLineColor,
2398
+ noiseStrength,
2399
+ } = opts
2400
+ // Shadow map is already created in setupPipelines()
1526
2401
  const gb = new Float32Array(16)
1527
- gb[0] = diffuseColor.x; gb[1] = diffuseColor.y; gb[2] = diffuseColor.z; gb[3] = fadeStart
1528
- gb[4] = fadeEnd; gb[5] = shadowStrength; gb[6] = 1 / shadowMapSize; gb[7] = gridSpacing
1529
- gb[8] = gridLineWidth; gb[9] = gridLineOpacity; gb[10] = noiseStrength; gb[11] = 0
1530
- gb[12] = gridLineColor.x; gb[13] = gridLineColor.y; gb[14] = gridLineColor.z; gb[15] = 0
1531
- this.groundShadowMaterialBuffer = this.device.createBuffer({ size: gb.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })
2402
+ gb[0] = diffuseColor.x
2403
+ gb[1] = diffuseColor.y
2404
+ gb[2] = diffuseColor.z
2405
+ gb[3] = fadeStart
2406
+ gb[4] = fadeEnd
2407
+ gb[5] = shadowStrength
2408
+ gb[6] = 1 / Engine.SHADOW_MAP_SIZE
2409
+ gb[7] = gridSpacing
2410
+ gb[8] = gridLineWidth
2411
+ gb[9] = gridLineOpacity
2412
+ gb[10] = noiseStrength
2413
+ gb[11] = 0
2414
+ gb[12] = gridLineColor.x
2415
+ gb[13] = gridLineColor.y
2416
+ gb[14] = gridLineColor.z
2417
+ gb[15] = 0
2418
+ this.groundShadowMaterialBuffer = this.device.createBuffer({
2419
+ size: gb.byteLength,
2420
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2421
+ })
1532
2422
  this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb)
1533
2423
  this.groundShadowBindGroup = this.device.createBindGroup({
1534
2424
  label: "ground shadow bind",
@@ -1544,12 +2434,12 @@ export class Engine {
1544
2434
  })
1545
2435
  }
1546
2436
 
1547
- // Shadow uses a fixed orthographic projection, independent of the visible light direction
2437
+ // Shadow is cast from the visible sun direction — same vector the shader lights with.
1548
2438
  private shadowLightVPDirty = true
1549
2439
  private updateShadowLightVP() {
1550
2440
  if (!this.shadowLightVPDirty) return
1551
2441
  this.shadowLightVPDirty = false
1552
- const dir = new Vec3(this.shadowLightDirection.x, this.shadowLightDirection.y, this.shadowLightDirection.z)
2442
+ const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z)
1553
2443
  dir.normalize()
1554
2444
  const target = new Vec3(0, 11, 0)
1555
2445
  const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72)
@@ -1589,14 +2479,11 @@ export class Engine {
1589
2479
  const materialAlpha = mat.diffuse[3]
1590
2480
  const isTransparent = materialAlpha < 1.0 - 0.001
1591
2481
 
1592
- const materialUniformBuffer = this.createMaterialUniformBuffer(
1593
- prefix + mat.name,
1594
- materialAlpha,
1595
- [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
1596
- mat.ambient,
1597
- mat.specular,
1598
- mat.shininess
1599
- )
2482
+ const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
2483
+ mat.diffuse[0],
2484
+ mat.diffuse[1],
2485
+ mat.diffuse[2],
2486
+ ])
1600
2487
  inst.gpuBuffers.push(materialUniformBuffer)
1601
2488
 
1602
2489
  const textureView = diffuseTexture.createView()
@@ -1610,24 +2497,43 @@ export class Engine {
1610
2497
  })
1611
2498
 
1612
2499
  const type: DrawCallType = isTransparent ? "transparent" : "opaque"
1613
- inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
2500
+ const preset = resolvePreset(mat.name, inst.materialPresets)
2501
+ inst.drawCalls.push({
2502
+ type,
2503
+ count: indexCount,
2504
+ firstIndex: currentIndexOffset,
2505
+ bindGroup,
2506
+ materialName: mat.name,
2507
+ preset,
2508
+ })
1614
2509
 
1615
2510
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1616
2511
  const materialUniformData = new Float32Array([
1617
- mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
1618
- mat.edgeSize, 0, 0, 0,
2512
+ mat.edgeColor[0],
2513
+ mat.edgeColor[1],
2514
+ mat.edgeColor[2],
2515
+ mat.edgeColor[3],
2516
+ mat.edgeSize,
2517
+ 0,
2518
+ 0,
2519
+ 0,
1619
2520
  ])
1620
2521
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
1621
2522
  inst.gpuBuffers.push(outlineUniformBuffer)
1622
2523
  const outlineBindGroup = this.device.createBindGroup({
1623
2524
  label: `${prefix}outline: ${mat.name}`,
1624
2525
  layout: this.outlinePerMaterialBindGroupLayout,
1625
- entries: [
1626
- { binding: 0, resource: { buffer: outlineUniformBuffer } },
1627
- ],
2526
+ entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
1628
2527
  })
1629
2528
  const outlineType: DrawCallType = isTransparent ? "transparent-outline" : "opaque-outline"
1630
- inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name })
2529
+ inst.drawCalls.push({
2530
+ type: outlineType,
2531
+ count: indexCount,
2532
+ firstIndex: currentIndexOffset,
2533
+ bindGroup: outlineBindGroup,
2534
+ materialName: mat.name,
2535
+ preset,
2536
+ })
1631
2537
  }
1632
2538
 
1633
2539
  if (this.onRaycast) {
@@ -1650,25 +2556,13 @@ export class Engine {
1650
2556
  }
1651
2557
  }
1652
2558
 
1653
- private createMaterialUniformBuffer(
1654
- label: string,
1655
- alpha: number,
1656
- diffuseColor: [number, number, number],
1657
- ambientColor: [number, number, number],
1658
- specularColor: [number, number, number],
1659
- shininess: number
1660
- ): GPUBuffer {
1661
- const data = new Float32Array(20)
1662
- data.set([
1663
- alpha,
1664
- this.rimLightIntensity,
1665
- shininess,
1666
- 0.0,
1667
- 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1668
- diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1669
- ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1670
- specularColor[0], specularColor[1], specularColor[2], 0.0,
1671
- ])
2559
+ private createMaterialUniformBuffer(label: string, alpha: number, diffuseColor: [number, number, number]): GPUBuffer {
2560
+ // Matches WGSL `struct MaterialUniforms { diffuseColor: vec3f, alpha: f32 }` — 16 bytes.
2561
+ const data = new Float32Array(4)
2562
+ data[0] = diffuseColor[0]
2563
+ data[1] = diffuseColor[1]
2564
+ data[2] = diffuseColor[2]
2565
+ data[3] = alpha
1672
2566
  return this.createUniformBuffer(`material uniform: ${label}`, data)
1673
2567
  }
1674
2568
 
@@ -1700,10 +2594,12 @@ export class Engine {
1700
2594
  colorSpaceConversion: "none",
1701
2595
  })
1702
2596
 
2597
+ const mipLevelCount = Math.floor(Math.log2(Math.max(imageBitmap.width, imageBitmap.height))) + 1
1703
2598
  const texture = this.device.createTexture({
1704
2599
  label: `texture: ${cacheKey}`,
1705
2600
  size: [imageBitmap.width, imageBitmap.height],
1706
- format: "rgba8unorm",
2601
+ format: "rgba8unorm-srgb",
2602
+ mipLevelCount,
1707
2603
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1708
2604
  })
1709
2605
  this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
@@ -1711,6 +2607,8 @@ export class Engine {
1711
2607
  imageBitmap.height,
1712
2608
  ])
1713
2609
 
2610
+ if (mipLevelCount > 1) this.generateMipmaps(texture, mipLevelCount)
2611
+
1714
2612
  this.textureCache.set(cacheKey, texture)
1715
2613
  inst.textureCacheKeys.push(cacheKey)
1716
2614
  return texture
@@ -1719,6 +2617,63 @@ export class Engine {
1719
2617
  }
1720
2618
  }
1721
2619
 
2620
+ // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
2621
+ // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
2622
+ private generateMipmaps(texture: GPUTexture, mipLevelCount: number) {
2623
+ if (!this.mipBlitPipeline || !this.mipBlitSampler) {
2624
+ this.mipBlitSampler = this.device.createSampler({
2625
+ magFilter: "linear",
2626
+ minFilter: "linear",
2627
+ addressModeU: "clamp-to-edge",
2628
+ addressModeV: "clamp-to-edge",
2629
+ })
2630
+ const module = this.device.createShaderModule({
2631
+ label: "mipmap blit",
2632
+ code: /* wgsl */ `
2633
+ @group(0) @binding(0) var src: texture_2d<f32>;
2634
+ @group(0) @binding(1) var samp: sampler;
2635
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
2636
+ let x = f32((vi & 1u) << 2u) - 1.0;
2637
+ let y = f32((vi & 2u) << 1u) - 1.0;
2638
+ return vec4f(x, y, 0.0, 1.0);
2639
+ }
2640
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
2641
+ let dstDims = vec2f(textureDimensions(src)) * 0.5;
2642
+ let uv = p.xy / max(dstDims, vec2f(1.0));
2643
+ return textureSampleLevel(src, samp, uv, 0.0);
2644
+ }
2645
+ `,
2646
+ })
2647
+ this.mipBlitPipeline = this.device.createRenderPipeline({
2648
+ label: "mipmap blit pipeline",
2649
+ layout: "auto",
2650
+ vertex: { module, entryPoint: "vs" },
2651
+ fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
2652
+ primitive: { topology: "triangle-list" },
2653
+ })
2654
+ }
2655
+
2656
+ const encoder = this.device.createCommandEncoder({ label: "mipgen" })
2657
+ for (let level = 1; level < mipLevelCount; level++) {
2658
+ const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 })
2659
+ const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 })
2660
+ const bindGroup = this.device.createBindGroup({
2661
+ layout: this.mipBlitPipeline.getBindGroupLayout(0),
2662
+ entries: [
2663
+ { binding: 0, resource: srcView },
2664
+ { binding: 1, resource: this.mipBlitSampler },
2665
+ ],
2666
+ })
2667
+ const pass = encoder.beginRenderPass({
2668
+ colorAttachments: [{ view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" }],
2669
+ })
2670
+ pass.setPipeline(this.mipBlitPipeline)
2671
+ pass.setBindGroup(0, bindGroup)
2672
+ pass.draw(3)
2673
+ pass.end()
2674
+ }
2675
+ this.device.queue.submit([encoder.finish()])
2676
+ }
1722
2677
 
1723
2678
  private renderGround(pass: GPURenderPassEncoder) {
1724
2679
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
@@ -1729,7 +2684,6 @@ export class Engine {
1729
2684
  pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
1730
2685
  }
1731
2686
 
1732
-
1733
2687
  private handleCanvasDoubleClick = (event: MouseEvent) => {
1734
2688
  if (!this.onRaycast || this.modelInstances.size === 0) return
1735
2689
  const rect = this.canvas.getBoundingClientRect()
@@ -1777,12 +2731,14 @@ export class Engine {
1777
2731
  if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture) return
1778
2732
 
1779
2733
  const pass = encoder.beginRenderPass({
1780
- colorAttachments: [{
1781
- view: this.pickTexture.createView(),
1782
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
1783
- loadOp: "clear",
1784
- storeOp: "store",
1785
- }],
2734
+ colorAttachments: [
2735
+ {
2736
+ view: this.pickTexture.createView(),
2737
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
2738
+ loadOp: "clear",
2739
+ storeOp: "store",
2740
+ },
2741
+ ],
1786
2742
  depthStencilAttachment: {
1787
2743
  view: this.pickDepthTexture.createView(),
1788
2744
  depthClearValue: 1.0,
@@ -1814,7 +2770,7 @@ export class Engine {
1814
2770
  encoder.copyTextureToBuffer(
1815
2771
  { texture: this.pickTexture, origin: { x: Math.max(0, px), y: Math.max(0, py) } },
1816
2772
  { buffer: this.pickReadbackBuffer, bytesPerRow: 256 },
1817
- { width: 1, height: 1 }
2773
+ { width: 1, height: 1 },
1818
2774
  )
1819
2775
  }
1820
2776
 
@@ -1835,7 +2791,10 @@ export class Engine {
1835
2791
  let idx = 1
1836
2792
  let hitModel = ""
1837
2793
  for (const [name] of this.modelInstances) {
1838
- if (idx === modelId) { hitModel = name; break }
2794
+ if (idx === modelId) {
2795
+ hitModel = name
2796
+ break
2797
+ }
1839
2798
  idx++
1840
2799
  }
1841
2800
 
@@ -1849,7 +2808,10 @@ export class Engine {
1849
2808
  for (const mat of materials) {
1850
2809
  if (mat.vertexCount === 0) continue
1851
2810
  matIdx++
1852
- if (matIdx === materialId) { hitMaterial = mat.name; break }
2811
+ if (matIdx === materialId) {
2812
+ hitMaterial = mat.name
2813
+ break
2814
+ }
1853
2815
  }
1854
2816
  }
1855
2817
  }
@@ -1864,8 +2826,6 @@ export class Engine {
1864
2826
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
1865
2827
  this.lastFrameTime = currentTime
1866
2828
 
1867
- this.updateRenderTarget()
1868
-
1869
2829
  const hasModels = this.modelInstances.size > 0
1870
2830
  if (hasModels) {
1871
2831
  this.updateInstances(deltaTime)
@@ -1883,10 +2843,10 @@ export class Engine {
1883
2843
  }
1884
2844
 
1885
2845
  this.updateCameraUniforms()
1886
- if (this.hasGround) this.updateShadowLightVP()
2846
+ this.updateShadowLightVP()
1887
2847
 
1888
2848
  const encoder = this.device.createCommandEncoder()
1889
- if (hasModels && this.hasGround && this.shadowMapDepthView) {
2849
+ if (hasModels) {
1890
2850
  const sp = encoder.beginRenderPass({
1891
2851
  colorAttachments: [],
1892
2852
  depthStencilAttachment: {
@@ -1906,6 +2866,56 @@ export class Engine {
1906
2866
  if (this.hasGround) this.renderGround(pass)
1907
2867
  pass.end()
1908
2868
 
2869
+ // Bloom pyramid (EEVEE 3.6):
2870
+ // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
2871
+ // 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
2872
+ // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
2873
+ // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
2874
+ // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
2875
+ if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
2876
+ const bloomAtt = this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
2877
+
2878
+ // 1. Blit
2879
+ bloomAtt[0].view = this.bloomDownMipViews[0]
2880
+ const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor)
2881
+ pBlit.setPipeline(this.bloomBlitPipeline)
2882
+ pBlit.setBindGroup(0, this.bloomBlitBindGroup)
2883
+ pBlit.draw(3)
2884
+ pBlit.end()
2885
+
2886
+ // 2. Downsample chain
2887
+ for (let i = 1; i < this.bloomMipCount; i++) {
2888
+ bloomAtt[0].view = this.bloomDownMipViews[i]
2889
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor)
2890
+ p.setPipeline(this.bloomDownsamplePipeline)
2891
+ p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1])
2892
+ p.draw(3)
2893
+ p.end()
2894
+ }
2895
+
2896
+ // 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
2897
+ const upSteps = this.bloomUpsampleBindGroups.length
2898
+ const topIdx = this.bloomMipCount - 2
2899
+ for (let k = 0; k < upSteps; k++) {
2900
+ const levelIdx = topIdx - k // writes bloomUp[levelIdx]
2901
+ bloomAtt[0].view = this.bloomUpMipViews[levelIdx]
2902
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor)
2903
+ p.setPipeline(this.bloomUpsamplePipeline)
2904
+ p.setBindGroup(0, this.bloomUpsampleBindGroups[k])
2905
+ p.draw(3)
2906
+ p.end()
2907
+ }
2908
+ }
2909
+
2910
+ // Composite: HDR + bloom → Filmic tonemap → swapchain.
2911
+ const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2912
+ compositeAttachment.view = this.context.getCurrentTexture().createView()
2913
+ const cpass = encoder.beginRenderPass(this.compositePassDescriptor)
2914
+ cpass.setPipeline(this.compositePipeline)
2915
+ cpass.setBindGroup(0, this.compositeBindGroup)
2916
+ cpass.draw(3)
2917
+ cpass.end()
2918
+
1909
2919
  const pick = this.pendingPick
1910
2920
  if (pick && hasModels) this.renderPickPass(encoder)
1911
2921
 
@@ -1920,11 +2930,6 @@ export class Engine {
1920
2930
  this.updateStats(performance.now() - currentTime)
1921
2931
  }
1922
2932
 
1923
- private updateRenderTarget() {
1924
- const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
1925
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
1926
- }
1927
-
1928
2933
  private drawInstanceShadow(sp: GPURenderPassEncoder, inst: ModelInstance): void {
1929
2934
  sp.setBindGroup(0, inst.shadowBindGroup)
1930
2935
  sp.setVertexBuffer(0, inst.vertexBuffer)
@@ -1936,46 +2941,83 @@ export class Engine {
1936
2941
  }
1937
2942
  }
1938
2943
 
1939
- private drawOpaque(pass: GPURenderPassEncoder, inst: ModelInstance, pipeline: GPURenderPipeline): void {
1940
- pass.setPipeline(pipeline)
2944
+ private pipelineForPreset(preset: MaterialPreset): GPURenderPipeline {
2945
+ if (preset === "face") return this.facePipeline
2946
+ if (preset === "hair") return this.hairPipeline
2947
+ if (preset === "cloth_smooth") return this.clothSmoothPipeline
2948
+ if (preset === "cloth_rough") return this.clothRoughPipeline
2949
+ if (preset === "metal") return this.metalPipeline
2950
+ if (preset === "body") return this.bodyPipeline
2951
+ if (preset === "eye") return this.eyePipeline
2952
+ if (preset === "stockings") return this.stockingsPipeline
2953
+ return this.modelPipeline
2954
+ }
2955
+
2956
+ /**
2957
+ * Draw every material of a given type (`opaque` or `transparent`) using the main
2958
+ * pipeline(s). Binds the per-frame and per-instance groups once at the top of the
2959
+ * batch, then issues one draw per material. Early-outs if nothing to draw so we
2960
+ * don't waste bindings when a model has no transparents, etc.
2961
+ */
2962
+ private drawMaterials(pass: GPURenderPassEncoder, inst: ModelInstance, type: "opaque" | "transparent"): void {
2963
+ let currentPipeline: GPURenderPipeline | null = null
2964
+ let bound = false
1941
2965
  for (const draw of inst.drawCalls) {
1942
- if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1943
- pass.setBindGroup(2, draw.bindGroup)
1944
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2966
+ if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
2967
+ if (!bound) {
2968
+ pass.setBindGroup(0, this.perFrameBindGroup)
2969
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2970
+ bound = true
2971
+ }
2972
+ const pipeline = this.pipelineForPreset(draw.preset)
2973
+ if (pipeline !== currentPipeline) {
2974
+ pass.setPipeline(pipeline)
2975
+ currentPipeline = pipeline
1945
2976
  }
2977
+ pass.setBindGroup(2, draw.bindGroup)
2978
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1946
2979
  }
1947
2980
  }
1948
2981
 
1949
- private drawTransparent(pass: GPURenderPassEncoder, inst: ModelInstance, pipeline: GPURenderPipeline): void {
1950
- pass.setPipeline(pipeline)
2982
+ /**
2983
+ * Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
2984
+ * Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
2985
+ * every batch binds its own groups from scratch — the next drawMaterials call will
2986
+ * rebind group 0/1 correctly if needed.
2987
+ */
2988
+ private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, type: DrawCallType): void {
2989
+ let bound = false
1951
2990
  for (const draw of inst.drawCalls) {
1952
- if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1953
- pass.setBindGroup(2, draw.bindGroup)
1954
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2991
+ if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
2992
+ if (!bound) {
2993
+ pass.setPipeline(this.outlinePipeline)
2994
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup)
2995
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2996
+ bound = true
1955
2997
  }
2998
+ pass.setBindGroup(2, draw.bindGroup)
2999
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1956
3000
  }
1957
3001
  }
1958
3002
 
1959
- private bindMainGroups(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1960
- pass.setBindGroup(0, this.perFrameBindGroup)
1961
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
1962
- }
1963
-
3003
+ /**
3004
+ * Main-pass render sequence for one model instance:
3005
+ * 1) opaque bodies → 2) opaque outlines → 3) transparents → 4) transparent outlines.
3006
+ * Each batch binds the groups it needs, so switching between main and outline
3007
+ * pipelines is self-contained (no cross-batch dependencies).
3008
+ */
1964
3009
  private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1965
3010
  pass.setVertexBuffer(0, inst.vertexBuffer)
1966
3011
  pass.setVertexBuffer(1, inst.jointsBuffer)
1967
3012
  pass.setVertexBuffer(2, inst.weightsBuffer)
1968
3013
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
1969
3014
 
1970
- this.bindMainGroups(pass, inst)
1971
- this.drawOpaque(pass, inst, this.modelPipeline)
1972
- this.drawOutlines(pass, inst, false)
1973
- this.bindMainGroups(pass, inst)
1974
- this.drawTransparent(pass, inst, this.modelPipeline)
1975
- this.drawOutlines(pass, inst, true)
3015
+ this.drawMaterials(pass, inst, "opaque")
3016
+ this.drawOutlines(pass, inst, "opaque-outline")
3017
+ this.drawMaterials(pass, inst, "transparent")
3018
+ this.drawOutlines(pass, inst, "transparent-outline")
1976
3019
  }
1977
3020
 
1978
-
1979
3021
  private updateCameraUniforms() {
1980
3022
  const viewMatrix = this.camera.getViewMatrix()
1981
3023
  const projectionMatrix = this.camera.getProjectionMatrix()
@@ -1988,7 +3030,6 @@ export class Engine {
1988
3030
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
1989
3031
  }
1990
3032
 
1991
-
1992
3033
  private updateSkinMatrices() {
1993
3034
  this.forEachInstance((inst) => {
1994
3035
  const skinMatrices = inst.model.getSkinMatrices()
@@ -1997,24 +3038,11 @@ export class Engine {
1997
3038
  0,
1998
3039
  skinMatrices.buffer,
1999
3040
  skinMatrices.byteOffset,
2000
- skinMatrices.byteLength
3041
+ skinMatrices.byteLength,
2001
3042
  )
2002
3043
  })
2003
3044
  }
2004
3045
 
2005
- private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean) {
2006
- pass.setPipeline(this.outlinePipeline)
2007
- pass.setBindGroup(0, this.outlinePerFrameBindGroup)
2008
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2009
- const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
2010
- for (const draw of inst.drawCalls) {
2011
- if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
2012
- pass.setBindGroup(2, draw.bindGroup)
2013
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2014
- }
2015
- }
2016
- }
2017
-
2018
3046
  private updateStats(frameTime: number) {
2019
3047
  // Simplified frame time tracking - rolling average with fixed window
2020
3048
  const maxSamples = 60
@@ -2039,5 +3067,4 @@ export class Engine {
2039
3067
  this.lastFpsUpdate = now
2040
3068
  }
2041
3069
  }
2042
-
2043
3070
  }