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.
Files changed (103) hide show
  1. package/LICENSE.txt +0 -0
  2. package/README.md +0 -0
  3. package/dist/Renderer.cjs +20844 -0
  4. package/dist/Renderer.cjs.map +1 -0
  5. package/dist/Renderer.js +20827 -0
  6. package/dist/Renderer.js.map +1 -0
  7. package/dist/client.cjs +91 -260
  8. package/dist/client.cjs.map +1 -1
  9. package/dist/client.js +68 -215
  10. package/dist/client.js.map +1 -1
  11. package/dist/server.cjs +165 -432
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +117 -370
  14. package/dist/server.js.map +1 -1
  15. package/dist/terminal.cjs +113 -200
  16. package/dist/terminal.cjs.map +1 -1
  17. package/dist/terminal.js +50 -51
  18. package/dist/terminal.js.map +1 -1
  19. package/dist/utils-CRhi1BDa.cjs +259 -0
  20. package/dist/utils-CRhi1BDa.cjs.map +1 -0
  21. package/dist/utils-D7tXt6-2.js +260 -0
  22. package/dist/utils-D7tXt6-2.js.map +1 -0
  23. package/package.json +19 -15
  24. package/src/{client.ts → network/client.js} +170 -403
  25. package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
  26. package/src/{compress-node.ts → network/compress-node.js} +8 -14
  27. package/src/{server.ts → network/server.js} +229 -317
  28. package/src/{terminal.js → network/terminal.js} +0 -0
  29. package/src/{topazcube.ts → network/topazcube.js} +2 -2
  30. package/src/network/utils.js +375 -0
  31. package/src/renderer/Camera.js +191 -0
  32. package/src/renderer/DebugUI.js +703 -0
  33. package/src/renderer/Geometry.js +1049 -0
  34. package/src/renderer/Material.js +64 -0
  35. package/src/renderer/Mesh.js +211 -0
  36. package/src/renderer/Node.js +112 -0
  37. package/src/renderer/Pipeline.js +645 -0
  38. package/src/renderer/Renderer.js +1496 -0
  39. package/src/renderer/Skin.js +792 -0
  40. package/src/renderer/Texture.js +584 -0
  41. package/src/renderer/core/AssetManager.js +394 -0
  42. package/src/renderer/core/CullingSystem.js +308 -0
  43. package/src/renderer/core/EntityManager.js +541 -0
  44. package/src/renderer/core/InstanceManager.js +343 -0
  45. package/src/renderer/core/ParticleEmitter.js +358 -0
  46. package/src/renderer/core/ParticleSystem.js +564 -0
  47. package/src/renderer/core/SpriteSystem.js +349 -0
  48. package/src/renderer/gltf.js +563 -0
  49. package/src/renderer/math.js +161 -0
  50. package/src/renderer/rendering/HistoryBufferManager.js +333 -0
  51. package/src/renderer/rendering/ProbeCapture.js +1495 -0
  52. package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
  53. package/src/renderer/rendering/RenderGraph.js +2258 -0
  54. package/src/renderer/rendering/passes/AOPass.js +308 -0
  55. package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
  56. package/src/renderer/rendering/passes/BasePass.js +101 -0
  57. package/src/renderer/rendering/passes/BloomPass.js +420 -0
  58. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  59. package/src/renderer/rendering/passes/FogPass.js +445 -0
  60. package/src/renderer/rendering/passes/GBufferPass.js +730 -0
  61. package/src/renderer/rendering/passes/HiZPass.js +744 -0
  62. package/src/renderer/rendering/passes/LightingPass.js +753 -0
  63. package/src/renderer/rendering/passes/ParticlePass.js +841 -0
  64. package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
  65. package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
  66. package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
  67. package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
  68. package/src/renderer/rendering/passes/SSGIPass.js +266 -0
  69. package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
  70. package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
  71. package/src/renderer/rendering/passes/TransparentPass.js +831 -0
  72. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  73. package/src/renderer/rendering/shaders/ao.wgsl +182 -0
  74. package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
  75. package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
  76. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  77. package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
  78. package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
  79. package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
  80. package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
  81. package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
  82. package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
  83. package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
  84. package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
  85. package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
  86. package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
  87. package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
  88. package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
  89. package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
  90. package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
  91. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  92. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  93. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  94. package/src/renderer/utils/BoundingSphere.js +439 -0
  95. package/src/renderer/utils/Frustum.js +281 -0
  96. package/src/renderer/utils/Raycaster.js +761 -0
  97. package/dist/client.d.cts +0 -211
  98. package/dist/client.d.ts +0 -211
  99. package/dist/server.d.cts +0 -120
  100. package/dist/server.d.ts +0 -120
  101. package/dist/terminal.d.cts +0 -64
  102. package/dist/terminal.d.ts +0 -64
  103. 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 }