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.
- package/README.md +72 -13
- package/dist/engine.d.ts +170 -34
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1080 -308
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/shaders/body.d.ts +2 -0
- package/dist/shaders/body.d.ts.map +1 -0
- package/dist/shaders/body.js +209 -0
- package/dist/shaders/classify.d.ts +4 -0
- package/dist/shaders/classify.d.ts.map +1 -0
- package/dist/shaders/classify.js +12 -0
- package/dist/shaders/cloth_rough.d.ts +2 -0
- package/dist/shaders/cloth_rough.d.ts.map +1 -0
- package/dist/shaders/cloth_rough.js +172 -0
- package/dist/shaders/cloth_smooth.d.ts +2 -0
- package/dist/shaders/cloth_smooth.d.ts.map +1 -0
- package/dist/shaders/cloth_smooth.js +171 -0
- package/dist/shaders/default.d.ts +2 -0
- package/dist/shaders/default.d.ts.map +1 -0
- package/dist/shaders/default.js +168 -0
- package/dist/shaders/dfg_lut.d.ts +4 -0
- package/dist/shaders/dfg_lut.d.ts.map +1 -0
- package/dist/shaders/dfg_lut.js +125 -0
- package/dist/shaders/eye.d.ts +2 -0
- package/dist/shaders/eye.d.ts.map +1 -0
- package/dist/shaders/eye.js +142 -0
- package/dist/shaders/face.d.ts +2 -0
- package/dist/shaders/face.d.ts.map +1 -0
- package/dist/shaders/face.js +211 -0
- package/dist/shaders/hair.d.ts +2 -0
- package/dist/shaders/hair.d.ts.map +1 -0
- package/dist/shaders/hair.js +186 -0
- package/dist/shaders/ltc_mag_lut.d.ts +3 -0
- package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
- package/dist/shaders/ltc_mag_lut.js +1033 -0
- package/dist/shaders/metal.d.ts +2 -0
- package/dist/shaders/metal.d.ts.map +1 -0
- package/dist/shaders/metal.js +171 -0
- package/dist/shaders/nodes.d.ts +2 -0
- package/dist/shaders/nodes.d.ts.map +1 -0
- package/dist/shaders/nodes.js +423 -0
- package/dist/shaders/stockings.d.ts +2 -0
- package/dist/shaders/stockings.d.ts.map +1 -0
- package/dist/shaders/stockings.js +229 -0
- package/package.json +1 -1
- package/src/engine.ts +1281 -376
- package/src/index.ts +12 -2
- package/src/shaders/body.ts +211 -0
- package/src/shaders/classify.ts +25 -0
- package/src/shaders/cloth_rough.ts +174 -0
- package/src/shaders/cloth_smooth.ts +173 -0
- package/src/shaders/default.ts +169 -0
- package/src/shaders/dfg_lut.ts +127 -0
- package/src/shaders/eye.ts +143 -0
- package/src/shaders/face.ts +213 -0
- package/src/shaders/hair.ts +188 -0
- package/src/shaders/ltc_mag_lut.ts +1035 -0
- package/src/shaders/metal.ts +173 -0
- package/src/shaders/nodes.ts +424 -0
- 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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
118
|
-
private
|
|
119
|
-
private
|
|
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
|
-
|
|
141
|
-
private
|
|
142
|
-
private
|
|
143
|
-
private
|
|
144
|
-
//
|
|
145
|
-
private
|
|
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
|
|
152
|
-
private shadowMapDepthView
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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:
|
|
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
|
|
345
|
-
code:
|
|
346
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
664
|
+
const clothRoughShaderModule = this.device.createShaderModule({
|
|
665
|
+
label: "cloth rough NPR shader",
|
|
666
|
+
code: CLOTH_ROUGH_SHADER_WGSL,
|
|
667
|
+
})
|
|
437
668
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
679
|
+
const eyeShaderModule = this.device.createShaderModule({
|
|
680
|
+
label: "eye shader",
|
|
681
|
+
code: EYE_SHADER_WGSL,
|
|
682
|
+
})
|
|
461
683
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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: [
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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: [
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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: [
|
|
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:
|
|
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.
|
|
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(
|
|
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 {
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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, //
|
|
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
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
this.lightData[
|
|
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
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
this.
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
this.
|
|
1073
|
-
|
|
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
|
-
|
|
1076
|
-
this.
|
|
1077
|
-
|
|
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.
|
|
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(
|
|
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 {
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
|
1528
|
-
gb[
|
|
1529
|
-
gb[
|
|
1530
|
-
gb[
|
|
1531
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
-
|
|
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],
|
|
1618
|
-
mat.
|
|
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({
|
|
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
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
2724
|
+
this.updateShadowLightVP()
|
|
1887
2725
|
|
|
1888
2726
|
const encoder = this.device.createCommandEncoder()
|
|
1889
|
-
if (hasModels
|
|
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
|
|
1940
|
-
|
|
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
|
|
1943
|
-
|
|
1944
|
-
pass.
|
|
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
|
-
|
|
1950
|
-
|
|
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
|
|
1953
|
-
|
|
1954
|
-
pass.
|
|
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
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
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.
|
|
1971
|
-
this.
|
|
1972
|
-
this.
|
|
1973
|
-
this.
|
|
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
|
}
|