reze-engine 0.10.2 → 0.11.0

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