topazcube 0.1.31 → 0.1.35
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/LICENSE.txt +0 -0
- package/README.md +0 -0
- package/dist/Renderer.cjs +20844 -0
- package/dist/Renderer.cjs.map +1 -0
- package/dist/Renderer.js +20827 -0
- package/dist/Renderer.js.map +1 -0
- package/dist/client.cjs +91 -260
- package/dist/client.cjs.map +1 -1
- package/dist/client.js +68 -215
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +165 -432
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +117 -370
- package/dist/server.js.map +1 -1
- package/dist/terminal.cjs +113 -200
- package/dist/terminal.cjs.map +1 -1
- package/dist/terminal.js +50 -51
- package/dist/terminal.js.map +1 -1
- package/dist/utils-CRhi1BDa.cjs +259 -0
- package/dist/utils-CRhi1BDa.cjs.map +1 -0
- package/dist/utils-D7tXt6-2.js +260 -0
- package/dist/utils-D7tXt6-2.js.map +1 -0
- package/package.json +19 -15
- package/src/{client.ts → network/client.js} +170 -403
- package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
- package/src/{compress-node.ts → network/compress-node.js} +8 -14
- package/src/{server.ts → network/server.js} +229 -317
- package/src/{terminal.js → network/terminal.js} +0 -0
- package/src/{topazcube.ts → network/topazcube.js} +2 -2
- package/src/network/utils.js +375 -0
- package/src/renderer/Camera.js +191 -0
- package/src/renderer/DebugUI.js +703 -0
- package/src/renderer/Geometry.js +1049 -0
- package/src/renderer/Material.js +64 -0
- package/src/renderer/Mesh.js +211 -0
- package/src/renderer/Node.js +112 -0
- package/src/renderer/Pipeline.js +645 -0
- package/src/renderer/Renderer.js +1496 -0
- package/src/renderer/Skin.js +792 -0
- package/src/renderer/Texture.js +584 -0
- package/src/renderer/core/AssetManager.js +394 -0
- package/src/renderer/core/CullingSystem.js +308 -0
- package/src/renderer/core/EntityManager.js +541 -0
- package/src/renderer/core/InstanceManager.js +343 -0
- package/src/renderer/core/ParticleEmitter.js +358 -0
- package/src/renderer/core/ParticleSystem.js +564 -0
- package/src/renderer/core/SpriteSystem.js +349 -0
- package/src/renderer/gltf.js +563 -0
- package/src/renderer/math.js +161 -0
- package/src/renderer/rendering/HistoryBufferManager.js +333 -0
- package/src/renderer/rendering/ProbeCapture.js +1495 -0
- package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
- package/src/renderer/rendering/RenderGraph.js +2258 -0
- package/src/renderer/rendering/passes/AOPass.js +308 -0
- package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
- package/src/renderer/rendering/passes/BasePass.js +101 -0
- package/src/renderer/rendering/passes/BloomPass.js +420 -0
- package/src/renderer/rendering/passes/CRTPass.js +724 -0
- package/src/renderer/rendering/passes/FogPass.js +445 -0
- package/src/renderer/rendering/passes/GBufferPass.js +730 -0
- package/src/renderer/rendering/passes/HiZPass.js +744 -0
- package/src/renderer/rendering/passes/LightingPass.js +753 -0
- package/src/renderer/rendering/passes/ParticlePass.js +841 -0
- package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
- package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
- package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
- package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
- package/src/renderer/rendering/passes/SSGIPass.js +266 -0
- package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
- package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
- package/src/renderer/rendering/passes/TransparentPass.js +831 -0
- package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
- package/src/renderer/rendering/shaders/ao.wgsl +182 -0
- package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
- package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
- package/src/renderer/rendering/shaders/crt.wgsl +455 -0
- package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
- package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
- package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
- package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
- package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
- package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
- package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
- package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
- package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
- package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
- package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
- package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
- package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
- package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
- package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
- package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
- package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
- package/src/renderer/utils/BoundingSphere.js +439 -0
- package/src/renderer/utils/Frustum.js +281 -0
- package/src/renderer/utils/Raycaster.js +761 -0
- package/dist/client.d.cts +0 -211
- package/dist/client.d.ts +0 -211
- package/dist/server.d.cts +0 -120
- package/dist/server.d.ts +0 -120
- package/dist/terminal.d.cts +0 -64
- package/dist/terminal.d.ts +0 -64
- package/src/utils.ts +0 -403
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { BasePass } from "./BasePass.js"
|
|
2
|
+
import { Pipeline } from "../../Pipeline.js"
|
|
3
|
+
|
|
4
|
+
import crtWGSL from "../shaders/crt.wgsl"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CRTPass - CRT monitor simulation effect
|
|
8
|
+
*
|
|
9
|
+
* Applies retro CRT effects: screen curvature, scanlines, RGB convergence,
|
|
10
|
+
* phosphor mask, and vignette. Optionally upscales the input for a pixelated look.
|
|
11
|
+
*
|
|
12
|
+
* Input: SDR image from PostProcessPass (rendered to intermediate texture)
|
|
13
|
+
* Output: Final image with CRT effects to canvas
|
|
14
|
+
*/
|
|
15
|
+
class CRTPass extends BasePass {
|
|
16
|
+
constructor(engine = null) {
|
|
17
|
+
super('CRT', engine)
|
|
18
|
+
|
|
19
|
+
this.pipeline = null
|
|
20
|
+
this.inputTexture = null
|
|
21
|
+
|
|
22
|
+
// Upscaled texture (for pixelated look)
|
|
23
|
+
this.upscaledTexture = null
|
|
24
|
+
this.upscaledWidth = 0
|
|
25
|
+
this.upscaledHeight = 0
|
|
26
|
+
|
|
27
|
+
// Samplers
|
|
28
|
+
this.linearSampler = null
|
|
29
|
+
this.nearestSampler = null
|
|
30
|
+
|
|
31
|
+
// Phosphor mask texture (procedural placeholder)
|
|
32
|
+
this.phosphorMaskTexture = null
|
|
33
|
+
|
|
34
|
+
// Canvas dimensions
|
|
35
|
+
this.canvasWidth = 0
|
|
36
|
+
this.canvasHeight = 0
|
|
37
|
+
|
|
38
|
+
// Render size (before upscale)
|
|
39
|
+
this.renderWidth = 0
|
|
40
|
+
this.renderHeight = 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Settings getters
|
|
44
|
+
get crtSettings() { return this.engine?.settings?.crt ?? {} }
|
|
45
|
+
get crtEnabled() { return this.crtSettings.enabled ?? false }
|
|
46
|
+
get upscaleEnabled() { return this.crtSettings.upscaleEnabled ?? false }
|
|
47
|
+
get upscaleTarget() { return this.crtSettings.upscaleTarget ?? 4 }
|
|
48
|
+
get maxTextureSize() { return this.crtSettings.maxTextureSize ?? 4096 }
|
|
49
|
+
|
|
50
|
+
// Geometry
|
|
51
|
+
get curvature() { return this.crtSettings.curvature ?? 0.03 }
|
|
52
|
+
get cornerRadius() { return this.crtSettings.cornerRadius ?? 0.03 }
|
|
53
|
+
get zoom() { return this.crtSettings.zoom ?? 1.0 }
|
|
54
|
+
|
|
55
|
+
// Scanlines
|
|
56
|
+
get scanlineIntensity() { return this.crtSettings.scanlineIntensity ?? 0.25 }
|
|
57
|
+
get scanlineWidth() { return this.crtSettings.scanlineWidth ?? 0.5 }
|
|
58
|
+
get scanlineBrightBoost() { return this.crtSettings.scanlineBrightBoost ?? 1.0 }
|
|
59
|
+
get scanlineHeight() { return this.crtSettings.scanlineHeight ?? 3 } // pixels per scanline
|
|
60
|
+
|
|
61
|
+
// Convergence
|
|
62
|
+
get convergence() { return this.crtSettings.convergence ?? [0.5, 0.0, -0.5] }
|
|
63
|
+
|
|
64
|
+
// Phosphor mask
|
|
65
|
+
get maskType() {
|
|
66
|
+
const type = this.crtSettings.maskType ?? 'aperture'
|
|
67
|
+
switch (type) {
|
|
68
|
+
case 'none': return 0
|
|
69
|
+
case 'aperture': return 1
|
|
70
|
+
case 'slot': return 2
|
|
71
|
+
case 'shadow': return 3
|
|
72
|
+
default: return 1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
get maskIntensity() { return this.crtSettings.maskIntensity ?? 0.15 }
|
|
76
|
+
get maskScale() { return this.crtSettings.maskScale ?? 1.0 }
|
|
77
|
+
|
|
78
|
+
// Vignette
|
|
79
|
+
get vignetteIntensity() { return this.crtSettings.vignetteIntensity ?? 0.15 }
|
|
80
|
+
get vignetteSize() { return this.crtSettings.vignetteSize ?? 0.4 }
|
|
81
|
+
|
|
82
|
+
// Blur
|
|
83
|
+
get blurSize() { return this.crtSettings.blurSize ?? 0.5 }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Calculate brightness compensation for phosphor mask
|
|
87
|
+
* Pre-computed on CPU to avoid per-pixel calculation in shader
|
|
88
|
+
* @param {number} maskType - 0=none, 1=aperture, 2=slot, 3=shadow
|
|
89
|
+
* @param {number} intensity - mask intensity 0-1
|
|
90
|
+
* @returns {number} compensation multiplier
|
|
91
|
+
*/
|
|
92
|
+
_calculateMaskCompensation(maskType, intensity) {
|
|
93
|
+
if (maskType < 0.5 || intensity <= 0) {
|
|
94
|
+
return 1.0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Darkening factors tuned per mask type
|
|
98
|
+
let darkening
|
|
99
|
+
let useLinearOnly = false
|
|
100
|
+
|
|
101
|
+
if (maskType < 1.5) {
|
|
102
|
+
// Aperture grille
|
|
103
|
+
darkening = 0.25
|
|
104
|
+
} else if (maskType < 2.5) {
|
|
105
|
+
// Slot mask
|
|
106
|
+
darkening = 0.27
|
|
107
|
+
} else {
|
|
108
|
+
// Shadow mask: linear formula works perfectly, don't blend to exp
|
|
109
|
+
darkening = 0.82
|
|
110
|
+
useLinearOnly = true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Linear formula: 1 / (1 - intensity * darkening)
|
|
114
|
+
const linearComp = 1.0 / Math.max(1.0 - intensity * darkening, 0.1)
|
|
115
|
+
|
|
116
|
+
// Shadow uses linear only (works well at all intensities)
|
|
117
|
+
if (useLinearOnly) {
|
|
118
|
+
return linearComp
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Aperture/Slot: blend to exp at high intensities to avoid over-brightening
|
|
122
|
+
const expComp = Math.exp(intensity * darkening)
|
|
123
|
+
const t = Math.max(0, Math.min(1, (intensity - 0.4) / 0.2))
|
|
124
|
+
const blendFactor = t * t * (3 - 2 * t) // smoothstep
|
|
125
|
+
|
|
126
|
+
return linearComp * (1 - blendFactor) + expComp * blendFactor
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Set the input texture (from PostProcessPass intermediate output)
|
|
131
|
+
* @param {Object} texture - Input texture object
|
|
132
|
+
*/
|
|
133
|
+
setInputTexture(texture) {
|
|
134
|
+
if (this.inputTexture !== texture) {
|
|
135
|
+
this.inputTexture = texture
|
|
136
|
+
this._needsRebuild = true
|
|
137
|
+
this._blitPipeline = null // Invalidate blit pipeline (has bind group with old texture)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set the render size (before upscaling)
|
|
143
|
+
* @param {number} width - Render width
|
|
144
|
+
* @param {number} height - Render height
|
|
145
|
+
*/
|
|
146
|
+
setRenderSize(width, height) {
|
|
147
|
+
if (this.renderWidth !== width || this.renderHeight !== height) {
|
|
148
|
+
this.renderWidth = width
|
|
149
|
+
this.renderHeight = height
|
|
150
|
+
this._needsUpscaleRebuild = true
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Calculate the upscaled texture size
|
|
156
|
+
* @returns {{width: number, height: number, scale: number}}
|
|
157
|
+
*/
|
|
158
|
+
_calculateUpscaledSize() {
|
|
159
|
+
const renderW = this.renderWidth || this.canvasWidth
|
|
160
|
+
const renderH = this.renderHeight || this.canvasHeight
|
|
161
|
+
const maxSize = this.maxTextureSize
|
|
162
|
+
|
|
163
|
+
// Find the largest integer scale that fits within limits
|
|
164
|
+
// This avoids non-integer ratios that cause moiré/checkerboard patterns
|
|
165
|
+
let scale = this.upscaleTarget
|
|
166
|
+
while (scale > 1 && (renderW * scale > maxSize || renderH * scale > maxSize)) {
|
|
167
|
+
scale--
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Also limit to 2x canvas size (no benefit beyond display resolution)
|
|
171
|
+
const maxCanvasScale = 2.0
|
|
172
|
+
while (scale > 1 && (renderW * scale > this.canvasWidth * maxCanvasScale ||
|
|
173
|
+
renderH * scale > this.canvasHeight * maxCanvasScale)) {
|
|
174
|
+
scale--
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ensure at least 1x
|
|
178
|
+
scale = Math.max(scale, 1)
|
|
179
|
+
|
|
180
|
+
const targetW = renderW * scale
|
|
181
|
+
const targetH = renderH * scale
|
|
182
|
+
|
|
183
|
+
return { width: targetW, height: targetH, scale }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if actual upscaling is needed
|
|
188
|
+
* Returns true only if the upscaled texture would be larger than the input
|
|
189
|
+
*/
|
|
190
|
+
_needsUpscaling() {
|
|
191
|
+
if (!this.upscaleEnabled) return false
|
|
192
|
+
const { scale } = this._calculateUpscaledSize()
|
|
193
|
+
return scale > 1
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async _init() {
|
|
197
|
+
const { device } = this.engine
|
|
198
|
+
|
|
199
|
+
// Create samplers
|
|
200
|
+
this.linearSampler = device.createSampler({
|
|
201
|
+
label: 'CRT Linear Sampler',
|
|
202
|
+
minFilter: 'linear',
|
|
203
|
+
magFilter: 'linear',
|
|
204
|
+
addressModeU: 'mirror-repeat',
|
|
205
|
+
addressModeV: 'mirror-repeat',
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
this.nearestSampler = device.createSampler({
|
|
209
|
+
label: 'CRT Nearest Sampler',
|
|
210
|
+
minFilter: 'nearest',
|
|
211
|
+
magFilter: 'nearest',
|
|
212
|
+
addressModeU: 'mirror-repeat',
|
|
213
|
+
addressModeV: 'mirror-repeat',
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Create dummy phosphor mask texture (1x1 white - will be replaced)
|
|
217
|
+
await this._createPhosphorMaskTexture()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create phosphor mask texture
|
|
222
|
+
* This is a simple procedural texture for the aperture grille pattern
|
|
223
|
+
*/
|
|
224
|
+
async _createPhosphorMaskTexture() {
|
|
225
|
+
const { device } = this.engine
|
|
226
|
+
|
|
227
|
+
// Create a 6x2 texture for aperture grille pattern
|
|
228
|
+
// Pattern: R G B R G B (repeated vertically)
|
|
229
|
+
const size = 6
|
|
230
|
+
const data = new Uint8Array(size * 2 * 4)
|
|
231
|
+
|
|
232
|
+
// Aperture grille pattern
|
|
233
|
+
for (let y = 0; y < 2; y++) {
|
|
234
|
+
for (let x = 0; x < size; x++) {
|
|
235
|
+
const idx = (y * size + x) * 4
|
|
236
|
+
const phase = x % 3
|
|
237
|
+
|
|
238
|
+
// RGB stripes with some bleed
|
|
239
|
+
if (phase === 0) {
|
|
240
|
+
data[idx] = 255 // R
|
|
241
|
+
data[idx + 1] = 50 // G
|
|
242
|
+
data[idx + 2] = 50 // B
|
|
243
|
+
} else if (phase === 1) {
|
|
244
|
+
data[idx] = 50 // R
|
|
245
|
+
data[idx + 1] = 255 // G
|
|
246
|
+
data[idx + 2] = 50 // B
|
|
247
|
+
} else {
|
|
248
|
+
data[idx] = 50 // R
|
|
249
|
+
data[idx + 1] = 50 // G
|
|
250
|
+
data[idx + 2] = 255 // B
|
|
251
|
+
}
|
|
252
|
+
data[idx + 3] = 255 // A
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const texture = device.createTexture({
|
|
257
|
+
label: 'Phosphor Mask',
|
|
258
|
+
size: [size, 2, 1],
|
|
259
|
+
format: 'rgba8unorm',
|
|
260
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
device.queue.writeTexture(
|
|
264
|
+
{ texture },
|
|
265
|
+
data,
|
|
266
|
+
{ bytesPerRow: size * 4 },
|
|
267
|
+
{ width: size, height: 2 }
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
const sampler = device.createSampler({
|
|
271
|
+
label: 'Phosphor Mask Sampler',
|
|
272
|
+
minFilter: 'nearest',
|
|
273
|
+
magFilter: 'nearest',
|
|
274
|
+
addressModeU: 'repeat',
|
|
275
|
+
addressModeV: 'repeat',
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
this.phosphorMaskTexture = {
|
|
279
|
+
texture,
|
|
280
|
+
view: texture.createView(),
|
|
281
|
+
sampler
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Create or resize the upscaled texture
|
|
287
|
+
*/
|
|
288
|
+
async _createUpscaledTexture() {
|
|
289
|
+
const { device } = this.engine
|
|
290
|
+
|
|
291
|
+
const { width, height, scale } = this._calculateUpscaledSize()
|
|
292
|
+
|
|
293
|
+
// Skip if size hasn't changed
|
|
294
|
+
if (this.upscaledWidth === width && this.upscaledHeight === height) {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Destroy old texture
|
|
299
|
+
if (this.upscaledTexture?.texture) {
|
|
300
|
+
this.upscaledTexture.texture.destroy()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Create new upscaled texture
|
|
304
|
+
const texture = device.createTexture({
|
|
305
|
+
label: 'CRT Upscaled Texture',
|
|
306
|
+
size: [width, height, 1],
|
|
307
|
+
format: 'rgba8unorm',
|
|
308
|
+
usage: GPUTextureUsage.TEXTURE_BINDING |
|
|
309
|
+
GPUTextureUsage.RENDER_ATTACHMENT |
|
|
310
|
+
GPUTextureUsage.COPY_DST,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
this.upscaledTexture = {
|
|
314
|
+
texture,
|
|
315
|
+
view: texture.createView(),
|
|
316
|
+
width,
|
|
317
|
+
height,
|
|
318
|
+
scale,
|
|
319
|
+
format: 'rgba8unorm',
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.upscaledWidth = width
|
|
323
|
+
this.upscaledHeight = height
|
|
324
|
+
|
|
325
|
+
console.log(`CRTPass: Created upscaled texture ${width}x${height} (${scale.toFixed(1)}x)`)
|
|
326
|
+
|
|
327
|
+
this._needsRebuild = true
|
|
328
|
+
this._blitPipeline = null // Blit pipeline render target changed
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Build or rebuild the CRT pipeline
|
|
333
|
+
*/
|
|
334
|
+
async _buildPipeline() {
|
|
335
|
+
if (!this.inputTexture && !this.upscaledTexture) {
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const { device } = this.engine
|
|
340
|
+
|
|
341
|
+
// Check if actual upscaling is needed (scale > 1)
|
|
342
|
+
const needsUpscaling = this._needsUpscaling()
|
|
343
|
+
|
|
344
|
+
// Determine which texture to use as input
|
|
345
|
+
// When scale = 1, use input directly to avoid unnecessary copy and potential precision issues
|
|
346
|
+
const effectiveInput = (this.crtEnabled || this.upscaleEnabled) && needsUpscaling && this.upscaledTexture
|
|
347
|
+
? this.upscaledTexture
|
|
348
|
+
: this.inputTexture
|
|
349
|
+
|
|
350
|
+
if (!effectiveInput) return
|
|
351
|
+
|
|
352
|
+
// Create bind group layout
|
|
353
|
+
const bindGroupLayout = device.createBindGroupLayout({
|
|
354
|
+
label: 'CRT Bind Group Layout',
|
|
355
|
+
entries: [
|
|
356
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
|
357
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
|
|
358
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
|
|
359
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
|
|
360
|
+
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
|
|
361
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
|
|
362
|
+
]
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// Create uniform buffer (must match shader struct alignment)
|
|
366
|
+
// Total: 18 floats = 72 bytes, padded to 80 for alignment
|
|
367
|
+
const uniformBuffer = device.createBuffer({
|
|
368
|
+
label: 'CRT Uniforms',
|
|
369
|
+
size: 128, // Padded for alignment
|
|
370
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// Create bind group
|
|
374
|
+
const bindGroup = device.createBindGroup({
|
|
375
|
+
label: 'CRT Bind Group',
|
|
376
|
+
layout: bindGroupLayout,
|
|
377
|
+
entries: [
|
|
378
|
+
{ binding: 0, resource: { buffer: uniformBuffer } },
|
|
379
|
+
{ binding: 1, resource: effectiveInput.view },
|
|
380
|
+
{ binding: 2, resource: this.linearSampler },
|
|
381
|
+
{ binding: 3, resource: this.nearestSampler },
|
|
382
|
+
{ binding: 4, resource: this.phosphorMaskTexture.view },
|
|
383
|
+
{ binding: 5, resource: this.phosphorMaskTexture.sampler },
|
|
384
|
+
]
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
// Create pipeline layout
|
|
388
|
+
const pipelineLayout = device.createPipelineLayout({
|
|
389
|
+
label: 'CRT Pipeline Layout',
|
|
390
|
+
bindGroupLayouts: [bindGroupLayout]
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Create shader module
|
|
394
|
+
const shaderModule = device.createShaderModule({
|
|
395
|
+
label: 'CRT Shader',
|
|
396
|
+
code: crtWGSL,
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Create render pipeline
|
|
400
|
+
const pipeline = device.createRenderPipeline({
|
|
401
|
+
label: 'CRT Pipeline',
|
|
402
|
+
layout: pipelineLayout,
|
|
403
|
+
vertex: {
|
|
404
|
+
module: shaderModule,
|
|
405
|
+
entryPoint: 'vertexMain',
|
|
406
|
+
},
|
|
407
|
+
fragment: {
|
|
408
|
+
module: shaderModule,
|
|
409
|
+
entryPoint: 'fragmentMain',
|
|
410
|
+
targets: [{
|
|
411
|
+
format: navigator.gpu.getPreferredCanvasFormat(),
|
|
412
|
+
}],
|
|
413
|
+
},
|
|
414
|
+
primitive: {
|
|
415
|
+
topology: 'triangle-list',
|
|
416
|
+
},
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
this.pipeline = {
|
|
420
|
+
pipeline,
|
|
421
|
+
bindGroup,
|
|
422
|
+
uniformBuffer,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Track which textures the pipeline was built with
|
|
426
|
+
this._pipelineInputTexture = this.inputTexture
|
|
427
|
+
// Track effective upscaled texture (null when scale = 1)
|
|
428
|
+
this._pipelineUpscaledTexture = needsUpscaling ? this.upscaledTexture : null
|
|
429
|
+
|
|
430
|
+
this._needsRebuild = false
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Upscale the input texture using nearest-neighbor filtering
|
|
435
|
+
*/
|
|
436
|
+
_upscaleInput() {
|
|
437
|
+
if (!this.inputTexture || !this.upscaledTexture) return
|
|
438
|
+
|
|
439
|
+
const { device } = this.engine
|
|
440
|
+
|
|
441
|
+
// Create a simple blit pipeline for nearest-neighbor upscaling
|
|
442
|
+
// We use copyTextureToTexture if sizes match, otherwise render with nearest sampling
|
|
443
|
+
|
|
444
|
+
const commandEncoder = device.createCommandEncoder({ label: 'CRT Upscale' })
|
|
445
|
+
|
|
446
|
+
// If input and upscale sizes are the same, just copy
|
|
447
|
+
if (this.inputTexture.width === this.upscaledTexture.width &&
|
|
448
|
+
this.inputTexture.height === this.upscaledTexture.height) {
|
|
449
|
+
commandEncoder.copyTextureToTexture(
|
|
450
|
+
{ texture: this.inputTexture.texture },
|
|
451
|
+
{ texture: this.upscaledTexture.texture },
|
|
452
|
+
[this.inputTexture.width, this.inputTexture.height]
|
|
453
|
+
)
|
|
454
|
+
} else {
|
|
455
|
+
// Render with nearest sampling for upscale
|
|
456
|
+
// Check if blit pipeline needs recreation (input texture changed)
|
|
457
|
+
if (!this._blitPipeline || this._blitInputTexture !== this.inputTexture) {
|
|
458
|
+
this._createBlitPipeline()
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (this._blitPipeline) {
|
|
462
|
+
const renderPass = commandEncoder.beginRenderPass({
|
|
463
|
+
colorAttachments: [{
|
|
464
|
+
view: this.upscaledTexture.view,
|
|
465
|
+
loadOp: 'clear',
|
|
466
|
+
storeOp: 'store',
|
|
467
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
468
|
+
}]
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
renderPass.setPipeline(this._blitPipeline.pipeline)
|
|
472
|
+
renderPass.setBindGroup(0, this._blitPipeline.bindGroup)
|
|
473
|
+
renderPass.draw(3, 1, 0, 0)
|
|
474
|
+
renderPass.end()
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
device.queue.submit([commandEncoder.finish()])
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Create a simple nearest-neighbor blit pipeline
|
|
483
|
+
*/
|
|
484
|
+
_createBlitPipeline() {
|
|
485
|
+
if (!this.inputTexture) return
|
|
486
|
+
|
|
487
|
+
const { device } = this.engine
|
|
488
|
+
|
|
489
|
+
// Track which texture this pipeline was created for
|
|
490
|
+
this._blitInputTexture = this.inputTexture
|
|
491
|
+
|
|
492
|
+
const blitShader = `
|
|
493
|
+
struct VertexOutput {
|
|
494
|
+
@builtin(position) position: vec4f,
|
|
495
|
+
@location(0) uv: vec2f,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
@group(0) @binding(0) var inputTexture: texture_2d<f32>;
|
|
499
|
+
@group(0) @binding(1) var inputSampler: sampler;
|
|
500
|
+
|
|
501
|
+
@vertex
|
|
502
|
+
fn vertexMain(@builtin(vertex_index) idx: u32) -> VertexOutput {
|
|
503
|
+
var output: VertexOutput;
|
|
504
|
+
let x = f32(idx & 1u) * 4.0 - 1.0;
|
|
505
|
+
let y = f32(idx >> 1u) * 4.0 - 1.0;
|
|
506
|
+
output.position = vec4f(x, y, 0.0, 1.0);
|
|
507
|
+
output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);
|
|
508
|
+
return output;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@fragment
|
|
512
|
+
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
|
|
513
|
+
return textureSample(inputTexture, inputSampler, input.uv);
|
|
514
|
+
}
|
|
515
|
+
`
|
|
516
|
+
|
|
517
|
+
const shaderModule = device.createShaderModule({
|
|
518
|
+
label: 'Blit Shader',
|
|
519
|
+
code: blitShader,
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
const bindGroupLayout = device.createBindGroupLayout({
|
|
523
|
+
label: 'Blit Bind Group Layout',
|
|
524
|
+
entries: [
|
|
525
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
|
|
526
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
|
|
527
|
+
]
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
const bindGroup = device.createBindGroup({
|
|
531
|
+
label: 'Blit Bind Group',
|
|
532
|
+
layout: bindGroupLayout,
|
|
533
|
+
entries: [
|
|
534
|
+
{ binding: 0, resource: this.inputTexture.view },
|
|
535
|
+
{ binding: 1, resource: this.nearestSampler },
|
|
536
|
+
]
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
const pipeline = device.createRenderPipeline({
|
|
540
|
+
label: 'Blit Pipeline',
|
|
541
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
|
|
542
|
+
vertex: {
|
|
543
|
+
module: shaderModule,
|
|
544
|
+
entryPoint: 'vertexMain',
|
|
545
|
+
},
|
|
546
|
+
fragment: {
|
|
547
|
+
module: shaderModule,
|
|
548
|
+
entryPoint: 'fragmentMain',
|
|
549
|
+
targets: [{ format: 'rgba8unorm' }],
|
|
550
|
+
},
|
|
551
|
+
primitive: { topology: 'triangle-list' },
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
this._blitPipeline = { pipeline, bindGroup }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async _execute(context) {
|
|
558
|
+
const { device, canvas } = this.engine
|
|
559
|
+
|
|
560
|
+
// Check if actual upscaling is needed (scale > 1)
|
|
561
|
+
const needsUpscaling = this._needsUpscaling()
|
|
562
|
+
|
|
563
|
+
// Track which texture we'll actually use
|
|
564
|
+
const effectiveUpscaledTexture = needsUpscaling ? this.upscaledTexture : null
|
|
565
|
+
|
|
566
|
+
// Check if textures have changed (invalidates pipelines)
|
|
567
|
+
if (this.pipeline && (
|
|
568
|
+
this._pipelineInputTexture !== this.inputTexture ||
|
|
569
|
+
this._pipelineUpscaledTexture !== effectiveUpscaledTexture
|
|
570
|
+
)) {
|
|
571
|
+
this._needsRebuild = true
|
|
572
|
+
this._blitPipeline = null // Also invalidate blit pipeline
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Skip if nothing to do
|
|
576
|
+
if (!this.crtEnabled && !this.upscaleEnabled) {
|
|
577
|
+
// Passthrough - but we need an input texture
|
|
578
|
+
if (!this.inputTexture) return
|
|
579
|
+
|
|
580
|
+
// Rebuild pipeline if needed for passthrough
|
|
581
|
+
if (this._needsRebuild || !this.pipeline) {
|
|
582
|
+
await this._buildPipeline()
|
|
583
|
+
}
|
|
584
|
+
} else {
|
|
585
|
+
// CRT or upscale enabled
|
|
586
|
+
// Only create/use upscaled texture if actual upscaling is needed
|
|
587
|
+
if (needsUpscaling) {
|
|
588
|
+
if (this._needsUpscaleRebuild) {
|
|
589
|
+
await this._createUpscaledTexture()
|
|
590
|
+
this._needsUpscaleRebuild = false
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Upscale the input (blit pipeline is rebuilt inside if needed)
|
|
594
|
+
if (this.inputTexture && this.upscaledTexture) {
|
|
595
|
+
this._upscaleInput()
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Rebuild CRT pipeline if needed
|
|
600
|
+
if (this._needsRebuild || !this.pipeline) {
|
|
601
|
+
await this._buildPipeline()
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!this.pipeline) return
|
|
606
|
+
|
|
607
|
+
// Update uniforms
|
|
608
|
+
const uniformData = new Float32Array(32) // 128 bytes / 4
|
|
609
|
+
|
|
610
|
+
// Canvas size (vec2f)
|
|
611
|
+
uniformData[0] = this.canvasWidth
|
|
612
|
+
uniformData[1] = this.canvasHeight
|
|
613
|
+
|
|
614
|
+
// Input size (vec2f) - use upscaled only if actually upscaling
|
|
615
|
+
const inputW = needsUpscaling && this.upscaledTexture ? this.upscaledTexture.width : (this.inputTexture?.width || this.canvasWidth)
|
|
616
|
+
const inputH = needsUpscaling && this.upscaledTexture ? this.upscaledTexture.height : (this.inputTexture?.height || this.canvasHeight)
|
|
617
|
+
uniformData[2] = inputW
|
|
618
|
+
uniformData[3] = inputH
|
|
619
|
+
|
|
620
|
+
// Render size (vec2f) - determines scanline count when scanlineCount=0
|
|
621
|
+
const renderW = this.renderWidth || this.canvasWidth
|
|
622
|
+
const renderH = this.renderHeight || this.canvasHeight
|
|
623
|
+
uniformData[4] = renderW
|
|
624
|
+
uniformData[5] = renderH
|
|
625
|
+
|
|
626
|
+
// Debug: log dimensions on resize to verify pixel-perfect scanlines
|
|
627
|
+
if (!this._loggedDimensions) {
|
|
628
|
+
const renderScale = renderW > 0 ? (renderW / this.canvasWidth).toFixed(2) : '?'
|
|
629
|
+
const upscaleInfo = needsUpscaling ? `upscale=${this.upscaledTexture?.scale || '?'}x` : 'no-upscale (direct)'
|
|
630
|
+
console.log(`CRT: canvas=${this.canvasWidth}x${this.canvasHeight}, render=${renderW}x${renderH} (scale ${renderScale}), input=${inputW}x${inputH}, ${upscaleInfo}`)
|
|
631
|
+
console.log(`CRT: Scanlines use fragment coords (${this.canvasHeight}px), should repeat every ${this.scanlineHeight}px = ${Math.floor(this.canvasHeight / this.scanlineHeight)} scanlines`)
|
|
632
|
+
this._loggedDimensions = true
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Geometry (4 floats: curvature, cornerRadius, zoom, pad)
|
|
636
|
+
uniformData[6] = this.curvature
|
|
637
|
+
uniformData[7] = this.cornerRadius
|
|
638
|
+
uniformData[8] = this.zoom
|
|
639
|
+
uniformData[9] = 0 // _padGeom
|
|
640
|
+
|
|
641
|
+
// Scanlines (4 floats)
|
|
642
|
+
uniformData[10] = this.scanlineIntensity
|
|
643
|
+
uniformData[11] = this.scanlineWidth
|
|
644
|
+
uniformData[12] = this.scanlineBrightBoost
|
|
645
|
+
uniformData[13] = this.scanlineHeight // pixels per scanline (e.g. 3)
|
|
646
|
+
|
|
647
|
+
// Padding for vec3f alignment (convergence needs 16-byte alignment)
|
|
648
|
+
uniformData[14] = 0
|
|
649
|
+
uniformData[15] = 0
|
|
650
|
+
|
|
651
|
+
// Convergence (vec3f at offset 64, aligned to 16 bytes)
|
|
652
|
+
const conv = this.convergence
|
|
653
|
+
uniformData[16] = conv[0]
|
|
654
|
+
uniformData[17] = conv[1]
|
|
655
|
+
uniformData[18] = conv[2]
|
|
656
|
+
uniformData[19] = 0 // _pad2
|
|
657
|
+
|
|
658
|
+
// Phosphor mask (3 floats)
|
|
659
|
+
uniformData[20] = this.maskType
|
|
660
|
+
uniformData[21] = this.maskIntensity
|
|
661
|
+
uniformData[22] = this.maskScale
|
|
662
|
+
|
|
663
|
+
// Pre-calculate mask brightness compensation (avoid per-pixel calculation)
|
|
664
|
+
const maskCompensation = this._calculateMaskCompensation(this.maskType, this.maskIntensity)
|
|
665
|
+
uniformData[23] = maskCompensation
|
|
666
|
+
|
|
667
|
+
// Vignette (2 floats)
|
|
668
|
+
uniformData[24] = this.vignetteIntensity
|
|
669
|
+
uniformData[25] = this.vignetteSize
|
|
670
|
+
|
|
671
|
+
// Blur (1 float)
|
|
672
|
+
uniformData[26] = this.blurSize
|
|
673
|
+
|
|
674
|
+
// Flags (2 floats)
|
|
675
|
+
uniformData[27] = this.crtEnabled ? 1.0 : 0.0
|
|
676
|
+
uniformData[28] = this.upscaleEnabled ? 1.0 : 0.0
|
|
677
|
+
|
|
678
|
+
device.queue.writeBuffer(this.pipeline.uniformBuffer, 0, uniformData)
|
|
679
|
+
|
|
680
|
+
// Render to canvas
|
|
681
|
+
const commandEncoder = device.createCommandEncoder({ label: 'CRT Render' })
|
|
682
|
+
|
|
683
|
+
const canvasTexture = this.engine.context.getCurrentTexture()
|
|
684
|
+
const renderPass = commandEncoder.beginRenderPass({
|
|
685
|
+
colorAttachments: [{
|
|
686
|
+
view: canvasTexture.createView(),
|
|
687
|
+
loadOp: 'clear',
|
|
688
|
+
storeOp: 'store',
|
|
689
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
690
|
+
}]
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
renderPass.setPipeline(this.pipeline.pipeline)
|
|
694
|
+
renderPass.setBindGroup(0, this.pipeline.bindGroup)
|
|
695
|
+
renderPass.draw(3, 1, 0, 0)
|
|
696
|
+
renderPass.end()
|
|
697
|
+
|
|
698
|
+
device.queue.submit([commandEncoder.finish()])
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async _resize(width, height) {
|
|
702
|
+
this.canvasWidth = width
|
|
703
|
+
this.canvasHeight = height
|
|
704
|
+
this._needsUpscaleRebuild = true
|
|
705
|
+
this._needsRebuild = true
|
|
706
|
+
this._loggedDimensions = false // Re-log dimensions on resize
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
_destroy() {
|
|
710
|
+
if (this.pipeline?.uniformBuffer) {
|
|
711
|
+
this.pipeline.uniformBuffer.destroy()
|
|
712
|
+
}
|
|
713
|
+
if (this.upscaledTexture?.texture) {
|
|
714
|
+
this.upscaledTexture.texture.destroy()
|
|
715
|
+
}
|
|
716
|
+
if (this.phosphorMaskTexture?.texture) {
|
|
717
|
+
this.phosphorMaskTexture.texture.destroy()
|
|
718
|
+
}
|
|
719
|
+
this.pipeline = null
|
|
720
|
+
this._blitPipeline = null
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export { CRTPass }
|