emoemu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processing effects pipeline for CRT simulation.
|
|
3
|
+
*
|
|
4
|
+
* Effects are applied in this order:
|
|
5
|
+
* 1. Color adjustments (brightness, contrast, saturation)
|
|
6
|
+
* 2. NTSC artifacts (chroma blur)
|
|
7
|
+
* 3. Chromatic aberration (RGB color fringing)
|
|
8
|
+
* 4. Curvature (barrel distortion) - optionally combines scanlines + vignette
|
|
9
|
+
* 5. Scanlines (darken odd rows)
|
|
10
|
+
* 6. Bloom (glow from bright areas) - optionally combines vignette
|
|
11
|
+
* 7. Vignette (darken edges)
|
|
12
|
+
*
|
|
13
|
+
* NOTE: This file disables no-magic-numbers because the shader-like image
|
|
14
|
+
* processing code contains many mathematical coefficients (color science values,
|
|
15
|
+
* fixed-point arithmetic, bit shifts) that are standard constants in image
|
|
16
|
+
* processing. Extracting each would significantly harm readability of the
|
|
17
|
+
* pixel processing loops.
|
|
18
|
+
*
|
|
19
|
+
* Common values used:
|
|
20
|
+
* - 3: RGB bytes per pixel
|
|
21
|
+
* - 8: bit shift for fixed-point division (>> 8 = divide by 256)
|
|
22
|
+
* - 256: fixed-point scale factor (2^8)
|
|
23
|
+
* - 255: maximum 8-bit color channel value
|
|
24
|
+
* - 128: midpoint for contrast calculations
|
|
25
|
+
* - 0.299, 0.587, 0.114: ITU-R BT.601 luminance coefficients
|
|
26
|
+
* - 77, 150, 29: fast integer luminance coefficients (≈ 0.299*256, etc.)
|
|
27
|
+
* - YIQ conversion: NTSC color space coefficients
|
|
28
|
+
*/
|
|
29
|
+
/* eslint-disable @typescript-eslint/no-magic-numbers */
|
|
30
|
+
|
|
31
|
+
import { clamp } from 'remeda';
|
|
32
|
+
import { buildGammaLUT } from '../../utils/color';
|
|
33
|
+
import { DEFAULT_BLOOM_THRESHOLD } from '..';
|
|
34
|
+
import {
|
|
35
|
+
LUMA_R_INT, LUMA_G_INT, LUMA_B_INT,
|
|
36
|
+
CURVATURE_INTENSITY_SCALE,
|
|
37
|
+
NTSC_CHROMA_BLUR_RADIUS,
|
|
38
|
+
BLOOM_BLUR_RADIUS,
|
|
39
|
+
CHROMATIC_ABERRATION_OFFSET_SCALE,
|
|
40
|
+
YIQ_I_R, YIQ_I_G, YIQ_I_B,
|
|
41
|
+
YIQ_Q_R, YIQ_Q_G, YIQ_Q_B,
|
|
42
|
+
YIQ_INV_R_I, YIQ_INV_R_Q,
|
|
43
|
+
YIQ_INV_G_I, YIQ_INV_G_Q,
|
|
44
|
+
YIQ_INV_B_I, YIQ_INV_B_Q,
|
|
45
|
+
} from './consts';
|
|
46
|
+
|
|
47
|
+
export * from './consts';
|
|
48
|
+
|
|
49
|
+
export interface EffectOptions {
|
|
50
|
+
gamma?: number; // Gamma correction (default: 1.0, CRT-like: 1.1-1.4)
|
|
51
|
+
scanlines?: number; // Scanline intensity 0.0-1.0 (default: 0.0)
|
|
52
|
+
saturation?: number; // Color saturation multiplier (default: 1.0)
|
|
53
|
+
brightness?: number; // Brightness multiplier (default: 1.0)
|
|
54
|
+
contrast?: number; // Contrast multiplier (default: 1.0)
|
|
55
|
+
vignette?: number; // Vignette intensity (default: 0.0)
|
|
56
|
+
bloom?: number; // Bloom/glow intensity (default: 0.0)
|
|
57
|
+
bloomThreshold?: number; // Bloom brightness threshold (default: 0.6)
|
|
58
|
+
ntsc?: number; // NTSC artifact intensity (default: 0.0)
|
|
59
|
+
curvature?: number; // CRT curvature intensity (default: 0.0)
|
|
60
|
+
chromaticAberration?: number; // Chromatic aberration intensity (default: 0.0)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class PostProcessingPipeline {
|
|
64
|
+
// Options
|
|
65
|
+
private gamma: number;
|
|
66
|
+
private scanlineIntensity: number;
|
|
67
|
+
private saturation: number;
|
|
68
|
+
private brightness: number;
|
|
69
|
+
private contrast: number;
|
|
70
|
+
private vignette: number;
|
|
71
|
+
private bloom: number;
|
|
72
|
+
private bloomThreshold: number;
|
|
73
|
+
private ntsc: number;
|
|
74
|
+
private curvature: number;
|
|
75
|
+
private chromaticAberration: number;
|
|
76
|
+
|
|
77
|
+
// Precomputed lookup tables
|
|
78
|
+
private gammaLUT: Uint8Array;
|
|
79
|
+
|
|
80
|
+
// Vignette map
|
|
81
|
+
private vignetteMap: Uint16Array | null = null;
|
|
82
|
+
private vignetteMapWidth: number = 0;
|
|
83
|
+
private vignetteMapHeight: number = 0;
|
|
84
|
+
private vignetteMapIntensity: number = 0;
|
|
85
|
+
|
|
86
|
+
// Curvature map
|
|
87
|
+
private curvatureMap: Int32Array | null = null;
|
|
88
|
+
private curvatureMapWidth: number = 0;
|
|
89
|
+
private curvatureMapHeight: number = 0;
|
|
90
|
+
private curvatureMapIntensity: number = 0;
|
|
91
|
+
|
|
92
|
+
// Reusable buffers
|
|
93
|
+
private curvatureSrcBuffer: Uint8Array | null = null;
|
|
94
|
+
private bloomBuffer: Uint8Array | null = null;
|
|
95
|
+
private bloomTempRow: Uint8Array | null = null;
|
|
96
|
+
private bloomTempCol: Uint8Array | null = null;
|
|
97
|
+
private ntscChromaBuffer: Int32Array | null = null;
|
|
98
|
+
private ntscTempRow: Int32Array | null = null;
|
|
99
|
+
private chromaticAberrationSrcBuffer: Uint8Array | null = null;
|
|
100
|
+
|
|
101
|
+
// Chromatic aberration map (precomputed for performance)
|
|
102
|
+
// Stores source pixel indices: [redSrcIdx, blueSrcIdx] pairs for each destination pixel
|
|
103
|
+
private chromaticAberrationMap: Int32Array | null = null;
|
|
104
|
+
private chromaticAberrationMapWidth: number = 0;
|
|
105
|
+
private chromaticAberrationMapHeight: number = 0;
|
|
106
|
+
private chromaticAberrationMapIntensity: number = 0;
|
|
107
|
+
|
|
108
|
+
// Flags to track which effects have already been applied (to avoid duplicate passes)
|
|
109
|
+
private scanlinesApplied: boolean = false;
|
|
110
|
+
private vignetteApplied: boolean = false;
|
|
111
|
+
|
|
112
|
+
constructor(options: EffectOptions = {}) {
|
|
113
|
+
this.gamma = options.gamma ?? 1.0;
|
|
114
|
+
this.scanlineIntensity = clamp(options.scanlines ?? 0, { min: 0, max: 1 });
|
|
115
|
+
this.saturation = options.saturation ?? 1.0;
|
|
116
|
+
this.brightness = options.brightness ?? 1.0;
|
|
117
|
+
this.contrast = options.contrast ?? 1.0;
|
|
118
|
+
this.vignette = Math.max(0, options.vignette ?? 0);
|
|
119
|
+
this.bloom = Math.max(0, options.bloom ?? 0);
|
|
120
|
+
this.bloomThreshold = clamp(options.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD, { min: 0, max: 1 });
|
|
121
|
+
this.ntsc = Math.max(0, options.ntsc ?? 0);
|
|
122
|
+
this.curvature = Math.max(0, options.curvature ?? 0);
|
|
123
|
+
this.chromaticAberration = Math.max(0, options.chromaticAberration ?? 0);
|
|
124
|
+
this.gammaLUT = buildGammaLUT(this.gamma);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get the gamma lookup table for use during frame conversion.
|
|
129
|
+
*/
|
|
130
|
+
getGammaLUT(): Uint8Array {
|
|
131
|
+
return this.gammaLUT;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if any effects are enabled.
|
|
136
|
+
*/
|
|
137
|
+
hasEffects(): boolean {
|
|
138
|
+
return this.bloom > 0 ||
|
|
139
|
+
this.vignette > 0 ||
|
|
140
|
+
this.scanlineIntensity > 0 ||
|
|
141
|
+
this.ntsc > 0 ||
|
|
142
|
+
this.curvature > 0 ||
|
|
143
|
+
this.chromaticAberration > 0 ||
|
|
144
|
+
this.brightness !== 1.0 ||
|
|
145
|
+
this.contrast !== 1.0 ||
|
|
146
|
+
this.saturation !== 1.0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Apply all post-processing effects to the RGB buffer.
|
|
151
|
+
*
|
|
152
|
+
* Effect combination strategy (to minimize full-image iterations):
|
|
153
|
+
* - Vignette is combined with: bloom (if enabled) > curvature > scanlines > standalone
|
|
154
|
+
* - Scanlines are combined with: curvature (if enabled) > standalone
|
|
155
|
+
*
|
|
156
|
+
* This means:
|
|
157
|
+
* - If bloom is enabled: vignette is applied in bloom's final pass
|
|
158
|
+
* - Else if curvature is enabled: scanlines + vignette are applied in curvature pass
|
|
159
|
+
* - Else: scanlines + vignette can be combined in a single pass
|
|
160
|
+
*/
|
|
161
|
+
apply(buffer: Uint8Array, width: number, height: number): void {
|
|
162
|
+
// Early bailout when no effects are enabled
|
|
163
|
+
if (!this.hasEffects()) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Reset flags - these track whether effects were combined into an earlier pass
|
|
168
|
+
this.scanlinesApplied = false;
|
|
169
|
+
this.vignetteApplied = false;
|
|
170
|
+
|
|
171
|
+
// 1. Color adjustments (brightness, contrast, saturation)
|
|
172
|
+
this.applyColorAdjustments(buffer);
|
|
173
|
+
|
|
174
|
+
// 2. NTSC artifacts (chroma blur for color bleeding effect)
|
|
175
|
+
this.applyNtscArtifacts(buffer, width, height);
|
|
176
|
+
|
|
177
|
+
// 3. Chromatic aberration (RGB fringing toward edges)
|
|
178
|
+
this.applyChromaticAberration(buffer, width, height);
|
|
179
|
+
|
|
180
|
+
// 4. Spatial effects: curvature, scanlines, vignette
|
|
181
|
+
// These are combined where possible to reduce iterations
|
|
182
|
+
if (this.curvature > 0) {
|
|
183
|
+
// Curvature pass combines: curvature + scanlines + vignette (if no bloom)
|
|
184
|
+
this.applyCurvature(buffer, width, height);
|
|
185
|
+
} else if (this.scanlineIntensity > 0 || (this.vignette > 0 && this.bloom <= 0)) {
|
|
186
|
+
// No curvature: combine scanlines + vignette in single pass (if no bloom)
|
|
187
|
+
this.applyScanlinesAndVignette(buffer, width, height);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 5. Bloom (glow from bright areas) - combines vignette if enabled
|
|
191
|
+
this.applyBloom(buffer, width, height);
|
|
192
|
+
|
|
193
|
+
// 6. Apply any effects that weren't combined into earlier passes
|
|
194
|
+
// (these methods check the flags internally and return early if already applied)
|
|
195
|
+
this.applyScanlines(buffer, width, height);
|
|
196
|
+
this.applyVignette(buffer, width, height);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Combined brightness, contrast, and saturation adjustment.
|
|
201
|
+
*/
|
|
202
|
+
private applyColorAdjustments(buffer: Uint8Array): void {
|
|
203
|
+
const needsBrightness = this.brightness !== 1.0;
|
|
204
|
+
const needsContrast = this.contrast !== 1.0;
|
|
205
|
+
const needsSaturation = this.saturation !== 1.0;
|
|
206
|
+
|
|
207
|
+
if (!needsBrightness && !needsContrast && !needsSaturation) {return;}
|
|
208
|
+
|
|
209
|
+
const len = buffer.length;
|
|
210
|
+
const bright = this.brightness;
|
|
211
|
+
const con = this.contrast;
|
|
212
|
+
const sat = this.saturation;
|
|
213
|
+
|
|
214
|
+
for (let i = 0; i < len; i += 3) {
|
|
215
|
+
let r = buffer[i];
|
|
216
|
+
let g = buffer[i + 1];
|
|
217
|
+
let b = buffer[i + 2];
|
|
218
|
+
|
|
219
|
+
if (needsBrightness) {
|
|
220
|
+
r = r * bright;
|
|
221
|
+
g = g * bright;
|
|
222
|
+
b = b * bright;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (needsContrast) {
|
|
226
|
+
r = (r - 128) * con + 128;
|
|
227
|
+
g = (g - 128) * con + 128;
|
|
228
|
+
b = (b - 128) * con + 128;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (needsSaturation) {
|
|
232
|
+
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
233
|
+
r = gray + (r - gray) * sat;
|
|
234
|
+
g = gray + (g - gray) * sat;
|
|
235
|
+
b = gray + (b - gray) * sat;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
buffer[i] = r < 0 ? 0 : r > 255 ? 255 : r | 0;
|
|
239
|
+
buffer[i + 1] = g < 0 ? 0 : g > 255 ? 255 : g | 0;
|
|
240
|
+
buffer[i + 2] = b < 0 ? 0 : b > 255 ? 255 : b | 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Build vignette lookup map.
|
|
246
|
+
*/
|
|
247
|
+
private buildVignetteMap(width: number, height: number): void {
|
|
248
|
+
this.vignetteMap = new Uint16Array(width * height);
|
|
249
|
+
this.vignetteMapWidth = width;
|
|
250
|
+
this.vignetteMapHeight = height;
|
|
251
|
+
this.vignetteMapIntensity = this.vignette;
|
|
252
|
+
|
|
253
|
+
const halfW = width / 2;
|
|
254
|
+
const halfH = height / 2;
|
|
255
|
+
const invMaxDistSq = 1 / (halfW * halfW + halfH * halfH);
|
|
256
|
+
|
|
257
|
+
let idx = 0;
|
|
258
|
+
for (let y = 0; y < height; y++) {
|
|
259
|
+
const dy = y - halfH;
|
|
260
|
+
const dySq = dy * dy;
|
|
261
|
+
|
|
262
|
+
for (let x = 0; x < width; x++) {
|
|
263
|
+
const dx = x - halfW;
|
|
264
|
+
const normDistSq = (dx * dx + dySq) * invMaxDistSq;
|
|
265
|
+
let factor = 1 - this.vignette * normDistSq;
|
|
266
|
+
if (factor < 0) {factor = 0;}
|
|
267
|
+
this.vignetteMap[idx++] = (factor * 256) | 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Ensure vignette map exists and is up to date.
|
|
274
|
+
* Call this before any method that needs to use the vignette map.
|
|
275
|
+
*/
|
|
276
|
+
private ensureVignetteMap(width: number, height: number): void {
|
|
277
|
+
if (!this.vignetteMap ||
|
|
278
|
+
this.vignetteMapWidth !== width ||
|
|
279
|
+
this.vignetteMapHeight !== height ||
|
|
280
|
+
this.vignetteMapIntensity !== this.vignette) {
|
|
281
|
+
this.buildVignetteMap(width, height);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Ensure curvature map exists and is up to date.
|
|
287
|
+
*/
|
|
288
|
+
private ensureCurvatureMap(width: number, height: number): void {
|
|
289
|
+
if (!this.curvatureMap ||
|
|
290
|
+
this.curvatureMapWidth !== width ||
|
|
291
|
+
this.curvatureMapHeight !== height ||
|
|
292
|
+
this.curvatureMapIntensity !== this.curvature) {
|
|
293
|
+
this.buildCurvatureMap(width, height);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Ensure chromatic aberration map exists and is up to date.
|
|
299
|
+
*/
|
|
300
|
+
private ensureChromaticAberrationMap(width: number, height: number): void {
|
|
301
|
+
if (!this.chromaticAberrationMap ||
|
|
302
|
+
this.chromaticAberrationMapWidth !== width ||
|
|
303
|
+
this.chromaticAberrationMapHeight !== height ||
|
|
304
|
+
this.chromaticAberrationMapIntensity !== this.chromaticAberration) {
|
|
305
|
+
this.buildChromaticAberrationMap(width, height);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Ensure a Uint8Array buffer exists with the required size.
|
|
311
|
+
* Returns existing buffer if size matches, otherwise allocates new one.
|
|
312
|
+
*/
|
|
313
|
+
private ensureUint8Buffer(current: Uint8Array | null, size: number): Uint8Array {
|
|
314
|
+
if (!current || current.length !== size) {
|
|
315
|
+
return new Uint8Array(size);
|
|
316
|
+
}
|
|
317
|
+
return current;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Ensure an Int32Array buffer exists with the required size.
|
|
322
|
+
* Returns existing buffer if size matches, otherwise allocates new one.
|
|
323
|
+
*/
|
|
324
|
+
private ensureInt32Buffer(current: Int32Array | null, size: number): Int32Array {
|
|
325
|
+
if (!current || current.length !== size) {
|
|
326
|
+
return new Int32Array(size);
|
|
327
|
+
}
|
|
328
|
+
return current;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Apply vignette effect (standalone pass).
|
|
333
|
+
* Only runs if vignette wasn't already combined into an earlier pass.
|
|
334
|
+
*/
|
|
335
|
+
private applyVignette(buffer: Uint8Array, width: number, height: number): void {
|
|
336
|
+
if (this.vignette <= 0 || this.vignetteApplied) {return;}
|
|
337
|
+
|
|
338
|
+
this.ensureVignetteMap(width, height);
|
|
339
|
+
const map = this.vignetteMap!;
|
|
340
|
+
const len = width * height;
|
|
341
|
+
|
|
342
|
+
for (let i = 0; i < len; i++) {
|
|
343
|
+
const factor = map[i];
|
|
344
|
+
const idx = i * 3;
|
|
345
|
+
buffer[idx] = (buffer[idx] * factor) >> 8;
|
|
346
|
+
buffer[idx + 1] = (buffer[idx + 1] * factor) >> 8;
|
|
347
|
+
buffer[idx + 2] = (buffer[idx + 2] * factor) >> 8;
|
|
348
|
+
}
|
|
349
|
+
this.vignetteApplied = true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Apply scanline effect (standalone pass).
|
|
354
|
+
* Only runs if scanlines weren't already combined into an earlier pass.
|
|
355
|
+
*/
|
|
356
|
+
private applyScanlines(buffer: Uint8Array, width: number, height: number): void {
|
|
357
|
+
if (this.scanlineIntensity <= 0 || this.scanlinesApplied) {return;}
|
|
358
|
+
|
|
359
|
+
const rowBytes = width * 3;
|
|
360
|
+
const mult256 = ((1 - this.scanlineIntensity) * 256) | 0;
|
|
361
|
+
|
|
362
|
+
for (let y = 1; y < height; y += 2) {
|
|
363
|
+
const rowStart = y * rowBytes;
|
|
364
|
+
for (let x = 0; x < rowBytes; x++) {
|
|
365
|
+
buffer[rowStart + x] = (buffer[rowStart + x] * mult256) >> 8;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
this.scanlinesApplied = true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Combined scanlines + vignette pass (when curvature is disabled).
|
|
373
|
+
* Combines these effects into a single iteration when both are needed.
|
|
374
|
+
* Vignette is only applied here if bloom is disabled (otherwise bloom handles it).
|
|
375
|
+
*/
|
|
376
|
+
private applyScanlinesAndVignette(buffer: Uint8Array, width: number, height: number): void {
|
|
377
|
+
const needsScanlines = this.scanlineIntensity > 0;
|
|
378
|
+
const needsVignette = this.vignette > 0 && this.bloom <= 0; // Vignette goes in bloom if bloom is enabled
|
|
379
|
+
|
|
380
|
+
if (!needsScanlines && !needsVignette) {return;}
|
|
381
|
+
|
|
382
|
+
if (needsVignette) {
|
|
383
|
+
this.ensureVignetteMap(width, height);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const scanlineMult256 = needsScanlines ? ((1 - this.scanlineIntensity) * 256) | 0 : 256;
|
|
387
|
+
const vmap = needsVignette ? this.vignetteMap! : null;
|
|
388
|
+
|
|
389
|
+
if (needsScanlines && needsVignette) {
|
|
390
|
+
// Combined pass: both scanlines and vignette
|
|
391
|
+
for (let y = 0; y < height; y++) {
|
|
392
|
+
const rowStart = y * width;
|
|
393
|
+
const isOddRow = y & 1;
|
|
394
|
+
const rowMult = isOddRow ? scanlineMult256 : 256;
|
|
395
|
+
|
|
396
|
+
for (let x = 0; x < width; x++) {
|
|
397
|
+
const i = rowStart + x;
|
|
398
|
+
const idx = i * 3;
|
|
399
|
+
const vfactor = vmap![i];
|
|
400
|
+
const combinedMult = (rowMult * vfactor) >> 8;
|
|
401
|
+
buffer[idx] = (buffer[idx] * combinedMult) >> 8;
|
|
402
|
+
buffer[idx + 1] = (buffer[idx + 1] * combinedMult) >> 8;
|
|
403
|
+
buffer[idx + 2] = (buffer[idx + 2] * combinedMult) >> 8;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
this.scanlinesApplied = true;
|
|
407
|
+
this.vignetteApplied = true;
|
|
408
|
+
} else if (needsScanlines) {
|
|
409
|
+
// Scanlines only (vignette will be in bloom or not needed)
|
|
410
|
+
const rowBytes = width * 3;
|
|
411
|
+
for (let y = 1; y < height; y += 2) {
|
|
412
|
+
const rowStart = y * rowBytes;
|
|
413
|
+
for (let x = 0; x < rowBytes; x++) {
|
|
414
|
+
buffer[rowStart + x] = (buffer[rowStart + x] * scanlineMult256) >> 8;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
this.scanlinesApplied = true;
|
|
418
|
+
} else if (needsVignette) {
|
|
419
|
+
// Vignette only (no scanlines)
|
|
420
|
+
const pixelCount = width * height;
|
|
421
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
422
|
+
const factor = vmap![i];
|
|
423
|
+
const idx = i * 3;
|
|
424
|
+
buffer[idx] = (buffer[idx] * factor) >> 8;
|
|
425
|
+
buffer[idx + 1] = (buffer[idx + 1] * factor) >> 8;
|
|
426
|
+
buffer[idx + 2] = (buffer[idx + 2] * factor) >> 8;
|
|
427
|
+
}
|
|
428
|
+
this.vignetteApplied = true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Build curvature distortion map.
|
|
434
|
+
*/
|
|
435
|
+
private buildCurvatureMap(width: number, height: number): void {
|
|
436
|
+
const k = this.curvature * CURVATURE_INTENSITY_SCALE;
|
|
437
|
+
|
|
438
|
+
this.curvatureMap = new Int32Array(width * height);
|
|
439
|
+
this.curvatureMapWidth = width;
|
|
440
|
+
this.curvatureMapHeight = height;
|
|
441
|
+
this.curvatureMapIntensity = this.curvature;
|
|
442
|
+
|
|
443
|
+
const halfW = width / 2;
|
|
444
|
+
const halfH = height / 2;
|
|
445
|
+
const maxDim = Math.max(halfW, halfH);
|
|
446
|
+
|
|
447
|
+
let idx = 0;
|
|
448
|
+
for (let y = 0; y < height; y++) {
|
|
449
|
+
const ny = (y - halfH) / maxDim;
|
|
450
|
+
|
|
451
|
+
for (let x = 0; x < width; x++) {
|
|
452
|
+
const nx = (x - halfW) / maxDim;
|
|
453
|
+
const r2 = nx * nx + ny * ny;
|
|
454
|
+
const factor = 1 + k * r2;
|
|
455
|
+
const srcX = nx * factor * maxDim + halfW;
|
|
456
|
+
const srcY = ny * factor * maxDim + halfH;
|
|
457
|
+
const srcXi = Math.round(srcX);
|
|
458
|
+
const srcYi = Math.round(srcY);
|
|
459
|
+
|
|
460
|
+
if (srcXi >= 0 && srcXi < width && srcYi >= 0 && srcYi < height) {
|
|
461
|
+
this.curvatureMap[idx] = srcYi * width + srcXi;
|
|
462
|
+
} else {
|
|
463
|
+
this.curvatureMap[idx] = -1;
|
|
464
|
+
}
|
|
465
|
+
idx++;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Apply CRT curvature with optional scanlines and vignette.
|
|
472
|
+
* Combines these effects into a single iteration to minimize passes.
|
|
473
|
+
* Vignette is only combined here if bloom is disabled.
|
|
474
|
+
*/
|
|
475
|
+
private applyCurvature(buffer: Uint8Array, width: number, height: number): void {
|
|
476
|
+
if (this.curvature <= 0) {return;}
|
|
477
|
+
|
|
478
|
+
const pixelCount = width * height;
|
|
479
|
+
const bufferSize = pixelCount * 3;
|
|
480
|
+
|
|
481
|
+
this.ensureCurvatureMap(width, height);
|
|
482
|
+
this.curvatureSrcBuffer = this.ensureUint8Buffer(this.curvatureSrcBuffer, bufferSize);
|
|
483
|
+
|
|
484
|
+
const src = this.curvatureSrcBuffer;
|
|
485
|
+
const map = this.curvatureMap!;
|
|
486
|
+
src.set(buffer);
|
|
487
|
+
|
|
488
|
+
// Determine which effects to combine into this pass
|
|
489
|
+
const combineScanlines = this.scanlineIntensity > 0;
|
|
490
|
+
const combineVignette = this.vignette > 0 && this.bloom <= 0; // Vignette goes in bloom if enabled
|
|
491
|
+
const scanlineMult256 = combineScanlines ? ((1 - this.scanlineIntensity) * 256) | 0 : 256;
|
|
492
|
+
|
|
493
|
+
if (combineVignette) {
|
|
494
|
+
this.ensureVignetteMap(width, height);
|
|
495
|
+
}
|
|
496
|
+
const vmap = this.vignetteMap;
|
|
497
|
+
|
|
498
|
+
// Unified loop: multiplying by 256 then >> 8 is a no-op, so disabled effects have no cost
|
|
499
|
+
for (let y = 0; y < height; y++) {
|
|
500
|
+
const rowStart = y * width;
|
|
501
|
+
const rowMult = combineScanlines && (y & 1) ? scanlineMult256 : 256;
|
|
502
|
+
|
|
503
|
+
for (let x = 0; x < width; x++) {
|
|
504
|
+
const i = rowStart + x;
|
|
505
|
+
const srcIdx = map[i];
|
|
506
|
+
const dstIdx = i * 3;
|
|
507
|
+
|
|
508
|
+
if (srcIdx >= 0) {
|
|
509
|
+
const srcOffset = srcIdx * 3;
|
|
510
|
+
const vfactor = combineVignette ? vmap![i] : 256;
|
|
511
|
+
const finalMult = (rowMult * vfactor) >> 8;
|
|
512
|
+
buffer[dstIdx] = (src[srcOffset] * finalMult) >> 8;
|
|
513
|
+
buffer[dstIdx + 1] = (src[srcOffset + 1] * finalMult) >> 8;
|
|
514
|
+
buffer[dstIdx + 2] = (src[srcOffset + 2] * finalMult) >> 8;
|
|
515
|
+
} else {
|
|
516
|
+
buffer[dstIdx] = 0;
|
|
517
|
+
buffer[dstIdx + 1] = 0;
|
|
518
|
+
buffer[dstIdx + 2] = 0;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (combineScanlines) {this.scanlinesApplied = true;}
|
|
524
|
+
if (combineVignette) {this.vignetteApplied = true;}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Apply bloom/glow effect.
|
|
529
|
+
*/
|
|
530
|
+
private applyBloom(buffer: Uint8Array, width: number, height: number): void {
|
|
531
|
+
if (this.bloom <= 0) {return;}
|
|
532
|
+
|
|
533
|
+
const intensity256 = (this.bloom * 256) | 0;
|
|
534
|
+
const threshold = (this.bloomThreshold * 255) | 0;
|
|
535
|
+
const rowBytes = width * 3;
|
|
536
|
+
|
|
537
|
+
const halfW = (width + 1) >> 1;
|
|
538
|
+
const halfH = (height + 1) >> 1;
|
|
539
|
+
const halfRowBytes = halfW * 3;
|
|
540
|
+
const halfBufferSize = halfW * halfH * 3;
|
|
541
|
+
|
|
542
|
+
this.bloomBuffer = this.ensureUint8Buffer(this.bloomBuffer, halfBufferSize);
|
|
543
|
+
this.bloomTempRow = this.ensureUint8Buffer(this.bloomTempRow, halfRowBytes);
|
|
544
|
+
this.bloomTempCol = this.ensureUint8Buffer(this.bloomTempCol, halfH * 3);
|
|
545
|
+
|
|
546
|
+
const bloom = this.bloomBuffer;
|
|
547
|
+
const tempRow = this.bloomTempRow;
|
|
548
|
+
const tempCol = this.bloomTempCol;
|
|
549
|
+
|
|
550
|
+
// Downsample and extract bright pixels
|
|
551
|
+
const thresholdRange = 255 - threshold || 1;
|
|
552
|
+
for (let hy = 0; hy < halfH; hy++) {
|
|
553
|
+
const sy = hy * 2;
|
|
554
|
+
const sy1 = Math.min(sy + 1, height - 1);
|
|
555
|
+
for (let hx = 0; hx < halfW; hx++) {
|
|
556
|
+
const sx = hx * 2;
|
|
557
|
+
const sx1 = Math.min(sx + 1, width - 1);
|
|
558
|
+
|
|
559
|
+
const i00 = (sy * width + sx) * 3;
|
|
560
|
+
const i10 = (sy * width + sx1) * 3;
|
|
561
|
+
const i01 = (sy1 * width + sx) * 3;
|
|
562
|
+
const i11 = (sy1 * width + sx1) * 3;
|
|
563
|
+
|
|
564
|
+
const r = (buffer[i00] + buffer[i10] + buffer[i01] + buffer[i11]) >> 2;
|
|
565
|
+
const g = (buffer[i00 + 1] + buffer[i10 + 1] + buffer[i01 + 1] + buffer[i11 + 1]) >> 2;
|
|
566
|
+
const b = (buffer[i00 + 2] + buffer[i10 + 2] + buffer[i01 + 2] + buffer[i11 + 2]) >> 2;
|
|
567
|
+
|
|
568
|
+
const lum = (LUMA_R_INT * r + LUMA_G_INT * g + LUMA_B_INT * b) >> 8;
|
|
569
|
+
const outIdx = (hy * halfW + hx) * 3;
|
|
570
|
+
|
|
571
|
+
if (lum > threshold) {
|
|
572
|
+
const excess256 = ((lum - threshold) << 8) / thresholdRange;
|
|
573
|
+
bloom[outIdx] = (r * excess256) >> 8;
|
|
574
|
+
bloom[outIdx + 1] = (g * excess256) >> 8;
|
|
575
|
+
bloom[outIdx + 2] = (b * excess256) >> 8;
|
|
576
|
+
} else {
|
|
577
|
+
bloom[outIdx] = 0;
|
|
578
|
+
bloom[outIdx + 1] = 0;
|
|
579
|
+
bloom[outIdx + 2] = 0;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Horizontal blur
|
|
585
|
+
const radius = BLOOM_BLUR_RADIUS;
|
|
586
|
+
for (let y = 0; y < halfH; y++) {
|
|
587
|
+
const rowStart = y * halfRowBytes;
|
|
588
|
+
let sumR = 0, sumG = 0, sumB = 0;
|
|
589
|
+
for (let dx = 0; dx <= radius && dx < halfW; dx++) {
|
|
590
|
+
const idx = rowStart + dx * 3;
|
|
591
|
+
sumR += bloom[idx];
|
|
592
|
+
sumG += bloom[idx + 1];
|
|
593
|
+
sumB += bloom[idx + 2];
|
|
594
|
+
}
|
|
595
|
+
let windowSize = Math.min(radius + 1, halfW);
|
|
596
|
+
|
|
597
|
+
for (let x = 0; x < halfW; x++) {
|
|
598
|
+
const outIdx = x * 3;
|
|
599
|
+
tempRow[outIdx] = (sumR / windowSize) | 0;
|
|
600
|
+
tempRow[outIdx + 1] = (sumG / windowSize) | 0;
|
|
601
|
+
tempRow[outIdx + 2] = (sumB / windowSize) | 0;
|
|
602
|
+
|
|
603
|
+
const leftX = x - radius;
|
|
604
|
+
const rightX = x + radius + 1;
|
|
605
|
+
if (leftX >= 0) {
|
|
606
|
+
const leftIdx = rowStart + leftX * 3;
|
|
607
|
+
sumR -= bloom[leftIdx];
|
|
608
|
+
sumG -= bloom[leftIdx + 1];
|
|
609
|
+
sumB -= bloom[leftIdx + 2];
|
|
610
|
+
windowSize--;
|
|
611
|
+
}
|
|
612
|
+
if (rightX < halfW) {
|
|
613
|
+
const rightIdx = rowStart + rightX * 3;
|
|
614
|
+
sumR += bloom[rightIdx];
|
|
615
|
+
sumG += bloom[rightIdx + 1];
|
|
616
|
+
sumB += bloom[rightIdx + 2];
|
|
617
|
+
windowSize++;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
bloom.set(tempRow.subarray(0, halfRowBytes), rowStart);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Vertical blur
|
|
624
|
+
for (let x = 0; x < halfW; x++) {
|
|
625
|
+
const xOffset = x * 3;
|
|
626
|
+
let sumR = 0, sumG = 0, sumB = 0;
|
|
627
|
+
for (let dy = 0; dy <= radius && dy < halfH; dy++) {
|
|
628
|
+
const idx = dy * halfRowBytes + xOffset;
|
|
629
|
+
sumR += bloom[idx];
|
|
630
|
+
sumG += bloom[idx + 1];
|
|
631
|
+
sumB += bloom[idx + 2];
|
|
632
|
+
}
|
|
633
|
+
let windowSize = Math.min(radius + 1, halfH);
|
|
634
|
+
|
|
635
|
+
for (let y = 0; y < halfH; y++) {
|
|
636
|
+
const outIdx = y * 3;
|
|
637
|
+
tempCol[outIdx] = (sumR / windowSize) | 0;
|
|
638
|
+
tempCol[outIdx + 1] = (sumG / windowSize) | 0;
|
|
639
|
+
tempCol[outIdx + 2] = (sumB / windowSize) | 0;
|
|
640
|
+
|
|
641
|
+
const topY = y - radius;
|
|
642
|
+
const bottomY = y + radius + 1;
|
|
643
|
+
if (topY >= 0) {
|
|
644
|
+
const topIdx = topY * halfRowBytes + xOffset;
|
|
645
|
+
sumR -= bloom[topIdx];
|
|
646
|
+
sumG -= bloom[topIdx + 1];
|
|
647
|
+
sumB -= bloom[topIdx + 2];
|
|
648
|
+
windowSize--;
|
|
649
|
+
}
|
|
650
|
+
if (bottomY < halfH) {
|
|
651
|
+
const bottomIdx = bottomY * halfRowBytes + xOffset;
|
|
652
|
+
sumR += bloom[bottomIdx];
|
|
653
|
+
sumG += bloom[bottomIdx + 1];
|
|
654
|
+
sumB += bloom[bottomIdx + 2];
|
|
655
|
+
windowSize++;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
for (let y = 0; y < halfH; y++) {
|
|
660
|
+
const srcIdx = y * 3;
|
|
661
|
+
const dstIdx = y * halfRowBytes + xOffset;
|
|
662
|
+
bloom[dstIdx] = tempCol[srcIdx];
|
|
663
|
+
bloom[dstIdx + 1] = tempCol[srcIdx + 1];
|
|
664
|
+
bloom[dstIdx + 2] = tempCol[srcIdx + 2];
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Upsample and blend, combining with vignette if enabled
|
|
669
|
+
const combineVignette = this.vignette > 0;
|
|
670
|
+
|
|
671
|
+
if (combineVignette) {
|
|
672
|
+
this.ensureVignetteMap(width, height);
|
|
673
|
+
const vmap = this.vignetteMap!;
|
|
674
|
+
|
|
675
|
+
for (let y = 0; y < height; y++) {
|
|
676
|
+
const hy = y >> 1;
|
|
677
|
+
const srcRowStart = hy * halfRowBytes;
|
|
678
|
+
const dstRowStart = y * rowBytes;
|
|
679
|
+
const vignetteRowStart = y * width;
|
|
680
|
+
|
|
681
|
+
for (let x = 0; x < width; x++) {
|
|
682
|
+
const hx = x >> 1;
|
|
683
|
+
const bloomIdx = srcRowStart + hx * 3;
|
|
684
|
+
const dstIdx = dstRowStart + x * 3;
|
|
685
|
+
const vfactor = vmap[vignetteRowStart + x];
|
|
686
|
+
|
|
687
|
+
const blendedR = buffer[dstIdx] + ((bloom[bloomIdx] * intensity256) >> 8);
|
|
688
|
+
const blendedG = buffer[dstIdx + 1] + ((bloom[bloomIdx + 1] * intensity256) >> 8);
|
|
689
|
+
const blendedB = buffer[dstIdx + 2] + ((bloom[bloomIdx + 2] * intensity256) >> 8);
|
|
690
|
+
|
|
691
|
+
buffer[dstIdx] = ((blendedR > 255 ? 255 : blendedR) * vfactor) >> 8;
|
|
692
|
+
buffer[dstIdx + 1] = ((blendedG > 255 ? 255 : blendedG) * vfactor) >> 8;
|
|
693
|
+
buffer[dstIdx + 2] = ((blendedB > 255 ? 255 : blendedB) * vfactor) >> 8;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
this.vignetteApplied = true;
|
|
697
|
+
} else {
|
|
698
|
+
for (let y = 0; y < height; y++) {
|
|
699
|
+
const hy = y >> 1;
|
|
700
|
+
const srcRowStart = hy * halfRowBytes;
|
|
701
|
+
const dstRowStart = y * rowBytes;
|
|
702
|
+
|
|
703
|
+
for (let x = 0; x < width; x++) {
|
|
704
|
+
const hx = x >> 1;
|
|
705
|
+
const bloomIdx = srcRowStart + hx * 3;
|
|
706
|
+
const dstIdx = dstRowStart + x * 3;
|
|
707
|
+
|
|
708
|
+
const blendedR = buffer[dstIdx] + ((bloom[bloomIdx] * intensity256) >> 8);
|
|
709
|
+
const blendedG = buffer[dstIdx + 1] + ((bloom[bloomIdx + 1] * intensity256) >> 8);
|
|
710
|
+
const blendedB = buffer[dstIdx + 2] + ((bloom[bloomIdx + 2] * intensity256) >> 8);
|
|
711
|
+
|
|
712
|
+
buffer[dstIdx] = blendedR > 255 ? 255 : blendedR;
|
|
713
|
+
buffer[dstIdx + 1] = blendedG > 255 ? 255 : blendedG;
|
|
714
|
+
buffer[dstIdx + 2] = blendedB > 255 ? 255 : blendedB;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Apply NTSC color artifact effect using fixed-point integer math.
|
|
722
|
+
*
|
|
723
|
+
* YIQ color space coefficients (scaled by 256 for fixed-point):
|
|
724
|
+
* - RGB to I: 0.596, -0.274, -0.322 → 153, -70, -82
|
|
725
|
+
* - RGB to Q: 0.211, -0.523, 0.312 → 54, -134, 80
|
|
726
|
+
* - RGB to Y: 0.299, 0.587, 0.114 → 77, 150, 29
|
|
727
|
+
* - I/Q to R: 0.956, 0.621 → 245, 159
|
|
728
|
+
* - I/Q to G: -0.272, -0.647 → -70, -166
|
|
729
|
+
* - I/Q to B: -1.106, 1.703 → -283, 436
|
|
730
|
+
*/
|
|
731
|
+
private applyNtscArtifacts(buffer: Uint8Array, width: number, height: number): void {
|
|
732
|
+
if (this.ntsc <= 0) {return;}
|
|
733
|
+
|
|
734
|
+
const rowBytes = width * 3;
|
|
735
|
+
const halfW = (width + 1) >> 1;
|
|
736
|
+
const rowChromaSize = halfW * 2;
|
|
737
|
+
|
|
738
|
+
// Fixed-point intensity (scaled by 256)
|
|
739
|
+
const int256 = (this.ntsc * 256) | 0;
|
|
740
|
+
const oneMinusInt256 = 256 - int256;
|
|
741
|
+
|
|
742
|
+
this.ntscChromaBuffer = this.ensureInt32Buffer(this.ntscChromaBuffer, rowChromaSize);
|
|
743
|
+
this.ntscTempRow = this.ensureInt32Buffer(this.ntscTempRow, rowChromaSize);
|
|
744
|
+
|
|
745
|
+
const chromaRow = this.ntscChromaBuffer;
|
|
746
|
+
const blurredRow = this.ntscTempRow;
|
|
747
|
+
const radius = NTSC_CHROMA_BLUR_RADIUS;
|
|
748
|
+
const rowStep = this.scanlineIntensity > 0 ? 2 : 1;
|
|
749
|
+
|
|
750
|
+
for (let y = 0; y < height; y += rowStep) {
|
|
751
|
+
const rowStart = y * rowBytes;
|
|
752
|
+
|
|
753
|
+
// Extract I and Q chroma components (scaled by 256)
|
|
754
|
+
for (let hx = 0; hx < halfW; hx++) {
|
|
755
|
+
const sx = hx * 2;
|
|
756
|
+
const idx = rowStart + sx * 3;
|
|
757
|
+
const r = buffer[idx];
|
|
758
|
+
const g = buffer[idx + 1];
|
|
759
|
+
const b = buffer[idx + 2];
|
|
760
|
+
|
|
761
|
+
const chromaIdx = hx * 2;
|
|
762
|
+
chromaRow[chromaIdx] = YIQ_I_R * r - YIQ_I_G * g - YIQ_I_B * b; // I * 256
|
|
763
|
+
chromaRow[chromaIdx + 1] = YIQ_Q_R * r - YIQ_Q_G * g + YIQ_Q_B * b; // Q * 256
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Horizontal blur using sliding window
|
|
767
|
+
let sumI = 0, sumQ = 0;
|
|
768
|
+
for (let dx = 0; dx <= radius && dx < halfW; dx++) {
|
|
769
|
+
const idx = dx * 2;
|
|
770
|
+
sumI += chromaRow[idx];
|
|
771
|
+
sumQ += chromaRow[idx + 1];
|
|
772
|
+
}
|
|
773
|
+
let windowSize = Math.min(radius + 1, halfW);
|
|
774
|
+
|
|
775
|
+
for (let hx = 0; hx < halfW; hx++) {
|
|
776
|
+
const outIdx = hx * 2;
|
|
777
|
+
blurredRow[outIdx] = (sumI / windowSize) | 0;
|
|
778
|
+
blurredRow[outIdx + 1] = (sumQ / windowSize) | 0;
|
|
779
|
+
|
|
780
|
+
const leftX = hx - radius;
|
|
781
|
+
const rightX = hx + radius + 1;
|
|
782
|
+
|
|
783
|
+
if (leftX >= 0) {
|
|
784
|
+
const leftIdx = leftX * 2;
|
|
785
|
+
sumI -= chromaRow[leftIdx];
|
|
786
|
+
sumQ -= chromaRow[leftIdx + 1];
|
|
787
|
+
windowSize--;
|
|
788
|
+
}
|
|
789
|
+
if (rightX < halfW) {
|
|
790
|
+
const rightIdx = rightX * 2;
|
|
791
|
+
sumI += chromaRow[rightIdx];
|
|
792
|
+
sumQ += chromaRow[rightIdx + 1];
|
|
793
|
+
windowSize++;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Apply chroma blur and convert back to RGB
|
|
798
|
+
for (let x = 0; x < width; x++) {
|
|
799
|
+
const idx = rowStart + x * 3;
|
|
800
|
+
const r = buffer[idx];
|
|
801
|
+
const g = buffer[idx + 1];
|
|
802
|
+
const b = buffer[idx + 2];
|
|
803
|
+
|
|
804
|
+
// Luma (Y) - unscaled, 0-255 range
|
|
805
|
+
const luma = (LUMA_R_INT * r + LUMA_G_INT * g + LUMA_B_INT * b) >> 8;
|
|
806
|
+
|
|
807
|
+
// Original I and Q (scaled by 256)
|
|
808
|
+
const iOrig = YIQ_I_R * r - YIQ_I_G * g - YIQ_I_B * b;
|
|
809
|
+
const qOrig = YIQ_Q_R * r - YIQ_Q_G * g + YIQ_Q_B * b;
|
|
810
|
+
|
|
811
|
+
// Blurred I and Q (already scaled by 256)
|
|
812
|
+
const hx = x >> 1;
|
|
813
|
+
const chromaIdx = hx * 2;
|
|
814
|
+
const iBlur = blurredRow[chromaIdx];
|
|
815
|
+
const qBlur = blurredRow[chromaIdx + 1];
|
|
816
|
+
|
|
817
|
+
// Blend original and blurred chroma (result still scaled by 256)
|
|
818
|
+
const iFinal = (iOrig * oneMinusInt256 + iBlur * int256) >> 8;
|
|
819
|
+
const qFinal = (qOrig * oneMinusInt256 + qBlur * int256) >> 8;
|
|
820
|
+
|
|
821
|
+
// Convert YIQ back to RGB
|
|
822
|
+
// Coefficients scaled by 256, I/Q scaled by 256, so >> 16 total
|
|
823
|
+
const newR = luma + ((YIQ_INV_R_I * iFinal + YIQ_INV_R_Q * qFinal) >> 16);
|
|
824
|
+
const newG = luma + ((-YIQ_INV_G_I * iFinal - YIQ_INV_G_Q * qFinal) >> 16);
|
|
825
|
+
const newB = luma + ((-YIQ_INV_B_I * iFinal + YIQ_INV_B_Q * qFinal) >> 16);
|
|
826
|
+
|
|
827
|
+
buffer[idx] = newR < 0 ? 0 : newR > 255 ? 255 : newR;
|
|
828
|
+
buffer[idx + 1] = newG < 0 ? 0 : newG > 255 ? 255 : newG;
|
|
829
|
+
buffer[idx + 2] = newB < 0 ? 0 : newB > 255 ? 255 : newB;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Build chromatic aberration lookup map.
|
|
836
|
+
* Precomputes source pixel indices for red and blue channel sampling.
|
|
837
|
+
*/
|
|
838
|
+
private buildChromaticAberrationMap(width: number, height: number): void {
|
|
839
|
+
const intensity = this.chromaticAberration;
|
|
840
|
+
const maxOffset = intensity * CHROMATIC_ABERRATION_OFFSET_SCALE;
|
|
841
|
+
|
|
842
|
+
const centerX = width / 2;
|
|
843
|
+
const centerY = height / 2;
|
|
844
|
+
const maxDist = Math.sqrt(centerX * centerX + centerY * centerY);
|
|
845
|
+
|
|
846
|
+
// Store 2 indices per pixel: [redSrcIdx, blueSrcIdx]
|
|
847
|
+
this.chromaticAberrationMap = new Int32Array(width * height * 2);
|
|
848
|
+
this.chromaticAberrationMapWidth = width;
|
|
849
|
+
this.chromaticAberrationMapHeight = height;
|
|
850
|
+
this.chromaticAberrationMapIntensity = intensity;
|
|
851
|
+
|
|
852
|
+
let mapIdx = 0;
|
|
853
|
+
for (let y = 0; y < height; y++) {
|
|
854
|
+
const dy = y - centerY;
|
|
855
|
+
|
|
856
|
+
for (let x = 0; x < width; x++) {
|
|
857
|
+
const dx = x - centerX;
|
|
858
|
+
const dist = Math.sqrt(dx * dx + dy * dy) / maxDist;
|
|
859
|
+
const distSq = dist * dist; // Quadratic falloff
|
|
860
|
+
|
|
861
|
+
// Calculate direction from center (normalized)
|
|
862
|
+
const dirX = dist > 0 ? dx / (dist * maxDist) : 0;
|
|
863
|
+
const dirY = dist > 0 ? dy / (dist * maxDist) : 0;
|
|
864
|
+
|
|
865
|
+
const offset = maxOffset * distSq;
|
|
866
|
+
|
|
867
|
+
// Red channel: sample from position shifted outward
|
|
868
|
+
let redX = Math.round(x + dirX * offset);
|
|
869
|
+
let redY = Math.round(y + dirY * offset);
|
|
870
|
+
// Clamp to bounds
|
|
871
|
+
if (redX < 0) {redX = 0;} else if (redX >= width) {redX = width - 1;}
|
|
872
|
+
if (redY < 0) {redY = 0;} else if (redY >= height) {redY = height - 1;}
|
|
873
|
+
|
|
874
|
+
// Blue channel: sample from position shifted inward
|
|
875
|
+
let blueX = Math.round(x - dirX * offset);
|
|
876
|
+
let blueY = Math.round(y - dirY * offset);
|
|
877
|
+
// Clamp to bounds
|
|
878
|
+
if (blueX < 0) {blueX = 0;} else if (blueX >= width) {blueX = width - 1;}
|
|
879
|
+
if (blueY < 0) {blueY = 0;} else if (blueY >= height) {blueY = height - 1;}
|
|
880
|
+
|
|
881
|
+
// Store source pixel indices (byte offset / 3)
|
|
882
|
+
this.chromaticAberrationMap[mapIdx++] = redY * width + redX;
|
|
883
|
+
this.chromaticAberrationMap[mapIdx++] = blueY * width + blueX;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Apply chromatic aberration effect (RGB color fringing).
|
|
890
|
+
* Simulates CRT electron beam convergence errors and lens distortion
|
|
891
|
+
* where different color channels separate toward screen edges.
|
|
892
|
+
*/
|
|
893
|
+
private applyChromaticAberration(buffer: Uint8Array, width: number, height: number): void {
|
|
894
|
+
if (this.chromaticAberration <= 0) {return;}
|
|
895
|
+
|
|
896
|
+
this.ensureChromaticAberrationMap(width, height);
|
|
897
|
+
|
|
898
|
+
const bufferSize = width * height * 3;
|
|
899
|
+
this.chromaticAberrationSrcBuffer = this.ensureUint8Buffer(this.chromaticAberrationSrcBuffer, bufferSize);
|
|
900
|
+
|
|
901
|
+
const source = this.chromaticAberrationSrcBuffer;
|
|
902
|
+
source.set(buffer);
|
|
903
|
+
|
|
904
|
+
const map = this.chromaticAberrationMap!;
|
|
905
|
+
const pixelCount = width * height;
|
|
906
|
+
|
|
907
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
908
|
+
const mapIdx = i * 2;
|
|
909
|
+
const redSrcPixel = map[mapIdx];
|
|
910
|
+
const blueSrcPixel = map[mapIdx + 1];
|
|
911
|
+
const dstIdx = i * 3;
|
|
912
|
+
|
|
913
|
+
// Sample red from shifted position
|
|
914
|
+
buffer[dstIdx] = source[redSrcPixel * 3];
|
|
915
|
+
|
|
916
|
+
// Green stays at original position
|
|
917
|
+
buffer[dstIdx + 1] = source[dstIdx + 1];
|
|
918
|
+
|
|
919
|
+
// Sample blue from shifted position
|
|
920
|
+
buffer[dstIdx + 2] = source[blueSrcPixel * 3 + 2];
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|