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