mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-03-07 11:51:49 +09:00
better CRT/Composite shader
This commit is contained in:
@@ -2,26 +2,18 @@ if (!exec_args[1]) {
|
|||||||
printerrln("Usage: jpdectest image.jpg")
|
printerrln("Usage: jpdectest image.jpg")
|
||||||
}
|
}
|
||||||
|
|
||||||
filesystem.open("A", exec_args[1], "R")
|
const fullFilePath = _G.shell.resolvePathInput(exec_args[1])
|
||||||
|
const file = files.open(fullFilePath.full)
|
||||||
|
const fileLen = file.size
|
||||||
|
const infile = sys.malloc(file.size); file.pread(infile, fileLen, 0)
|
||||||
|
|
||||||
let status = com.getStatusCode(0)
|
//println("decoding")
|
||||||
let infile = undefined
|
|
||||||
if (0 != status) return status
|
|
||||||
|
|
||||||
|
|
||||||
let fileLen = filesystem.getFileLen("A")
|
|
||||||
println(`DMA reading ${fileLen} bytes from disk...`)
|
|
||||||
infile = sys.malloc(fileLen)
|
|
||||||
dma.comToRam(0, 0, infile, fileLen)
|
|
||||||
|
|
||||||
|
|
||||||
println("decoding")
|
|
||||||
|
|
||||||
// decode
|
// decode
|
||||||
const [imgw, imgh, channels, imageData] = graphics.decodeImageResample(infile, fileLen, -1, -1)
|
const [imgw, imgh, channels, imageData] = graphics.decodeImageResample(infile, fileLen, -1, -1)
|
||||||
|
|
||||||
println(`dim: ${imgw}x${imgh}`)
|
//println(`dim: ${imgw}x${imgh}`)
|
||||||
println(`converting to displayable format...`)
|
//println(`converting to displayable format...`)
|
||||||
|
|
||||||
// convert colour
|
// convert colour
|
||||||
graphics.setGraphicsMode(0)
|
graphics.setGraphicsMode(0)
|
||||||
|
|||||||
@@ -2,26 +2,18 @@ if (!exec_args[1]) {
|
|||||||
printerrln("Usage: jpdectesthigh image.jpg")
|
printerrln("Usage: jpdectesthigh image.jpg")
|
||||||
}
|
}
|
||||||
|
|
||||||
filesystem.open("A", exec_args[1], "R")
|
const fullFilePath = _G.shell.resolvePathInput(exec_args[1])
|
||||||
|
const file = files.open(fullFilePath.full)
|
||||||
|
const fileLen = file.size
|
||||||
|
const infile = sys.malloc(file.size); file.pread(infile, fileLen, 0)
|
||||||
|
|
||||||
let status = com.getStatusCode(0)
|
//println("decoding")
|
||||||
let infile = undefined
|
|
||||||
if (0 != status) return status
|
|
||||||
|
|
||||||
|
|
||||||
let fileLen = filesystem.getFileLen("A")
|
|
||||||
println(`DMA reading ${fileLen} bytes from disk...`)
|
|
||||||
infile = sys.malloc(fileLen)
|
|
||||||
dma.comToRam(0, 0, infile, fileLen)
|
|
||||||
|
|
||||||
|
|
||||||
println("decoding")
|
|
||||||
|
|
||||||
// decode
|
// decode
|
||||||
const [imgw, imgh, channels, imageData] = graphics.decodeImageResample(infile, fileLen, -1, -1)
|
const [imgw, imgh, channels, imageData] = graphics.decodeImageResample(infile, fileLen, -1, -1)
|
||||||
|
|
||||||
println(`dim: ${imgw}x${imgh}`)
|
//println(`dim: ${imgw}x${imgh}`)
|
||||||
println(`converting to displayable format...`)
|
//println(`converting to displayable format...`)
|
||||||
|
|
||||||
// convert colour
|
// convert colour
|
||||||
graphics.setGraphicsMode(4)
|
graphics.setGraphicsMode(4)
|
||||||
|
|||||||
322
tsvm_core/src/net/torvald/tsvm/shader_crt_post.frag
Normal file
322
tsvm_core/src/net/torvald/tsvm/shader_crt_post.frag
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CRT + NTSC Composite/S-Video Signal Simulation Shader (Enhanced Version)
|
||||||
|
// ============================================================================
|
||||||
|
// Features:
|
||||||
|
// - Runtime-switchable composite/S-Video mode (no recompilation)
|
||||||
|
// - Adjustable signal and CRT parameters via uniforms
|
||||||
|
// - Accurate NTSC color artifact simulation
|
||||||
|
// - Animated dot crawl effect
|
||||||
|
// - Trinitron phosphor mask
|
||||||
|
// - Optional bloom/glow effect
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// === UNIFORMS ===
|
||||||
|
uniform float time = 0.0; // Frame count
|
||||||
|
uniform vec2 resolution = vec2(640.0, 480.0); // Virtual resolution (e.g., 640x480)
|
||||||
|
uniform sampler2D u_texture; // Input texture
|
||||||
|
uniform vec2 flip = vec2(0.0, 0.0); // UV flip control (0,1 = flip Y)
|
||||||
|
|
||||||
|
// Signal mode: 0 = S-Video, 1 = Composite, 2 = CGA Composite
|
||||||
|
// Can be changed at runtime without recompilation
|
||||||
|
uniform int signalMode = 1; // Default should be 1 for composite
|
||||||
|
|
||||||
|
// CGA-specific settings
|
||||||
|
uniform float cgaHue; // Hue adjustment for CGA (default: 0.0, range: -PI to PI)
|
||||||
|
uniform float cgaSaturation; // Saturation multiplier for CGA (default: 1.0)
|
||||||
|
|
||||||
|
// Optional adjustable parameters (set reasonable defaults if not provided)
|
||||||
|
uniform float lumaFilterWidth; // Default: 1.5
|
||||||
|
uniform float chromaIFilterWidth; // Default: 3.5
|
||||||
|
uniform float chromaQFilterWidth; // Default: 6.0
|
||||||
|
uniform float compositeFilterWidth; // Default: 1.5
|
||||||
|
uniform float phosphorIntensity; // Default: 0.25
|
||||||
|
uniform float scanlineIntensity; // Default: 0.12
|
||||||
|
|
||||||
|
in vec2 v_texCoords;
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
// === CONSTANTS ===
|
||||||
|
const float PI = 3.14159265358979323846;
|
||||||
|
const float TAU = 6.28318530717958647692;
|
||||||
|
|
||||||
|
// NTSC color subcarrier: 3.579545 MHz
|
||||||
|
// At 640 pixels for ~52.6µs active video: cycles/pixel ≈ 0.2917
|
||||||
|
const float CC_PER_PIXEL = 0.2917;
|
||||||
|
|
||||||
|
// CGA specific: 14.318 MHz pixel clock = exactly 4× color subcarrier
|
||||||
|
// This means exactly 4 pixels per color cycle = 0.25 cycles per pixel
|
||||||
|
const float CGA_CC_PER_PIXEL = 0.25;
|
||||||
|
|
||||||
|
// Filter kernel radius (samples to each side)
|
||||||
|
const int FILTER_RADIUS = 12;
|
||||||
|
|
||||||
|
// === COLOR SPACE CONVERSION ===
|
||||||
|
// GLSL matrices are column-major
|
||||||
|
const mat3 RGB_TO_YIQ = mat3(
|
||||||
|
0.299, 0.596, 0.211, // Column 0: R coefficients for Y,I,Q
|
||||||
|
0.587, -0.274, -0.523, // Column 1: G coefficients
|
||||||
|
0.114, -0.322, 0.312 // Column 2: B coefficients
|
||||||
|
);
|
||||||
|
|
||||||
|
const mat3 YIQ_TO_RGB = mat3(
|
||||||
|
1.000, 1.000, 1.000, // Column 0: Y coefficients for R,G,B
|
||||||
|
0.956, -0.272, -1.107, // Column 1: I coefficients
|
||||||
|
0.621, -0.647, 1.704 // Column 2: Q coefficients
|
||||||
|
);
|
||||||
|
|
||||||
|
// === DEFAULT VALUES ===
|
||||||
|
// Used when uniforms aren't set (value of 0)
|
||||||
|
float getLumaFilter() {
|
||||||
|
return lumaFilterWidth > 0.0 ? lumaFilterWidth : 1.15;
|
||||||
|
}
|
||||||
|
float getChromaIFilter() {
|
||||||
|
return chromaIFilterWidth > 0.0 ? chromaIFilterWidth : 3.5;
|
||||||
|
}
|
||||||
|
float getChromaQFilter() {
|
||||||
|
return chromaQFilterWidth > 0.0 ? chromaQFilterWidth : 6.0;
|
||||||
|
}
|
||||||
|
float getCompositeFilter() {
|
||||||
|
return compositeFilterWidth > 0.0 ? compositeFilterWidth : 1.35;
|
||||||
|
}
|
||||||
|
float getPhosphorStrength() {
|
||||||
|
return phosphorIntensity > 0.0 ? phosphorIntensity : 0.25;
|
||||||
|
}
|
||||||
|
float getScanlineStrength() {
|
||||||
|
return scanlineIntensity > 0.0 ? scanlineIntensity : 0.12;
|
||||||
|
}
|
||||||
|
float getCgaSaturation() {
|
||||||
|
return cgaSaturation > 0.0 ? cgaSaturation : 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === HELPER FUNCTIONS ===
|
||||||
|
|
||||||
|
float gaussianWeight(float x, float sigma) {
|
||||||
|
return exp(-0.5 * x * x / (sigma * sigma));
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 sampleTexture(vec2 uv) {
|
||||||
|
return texture(u_texture, clamp(uv, 0.0, 1.0)).rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
float calcCarrierPhase(float pixelX, float pixelY, float frameOffset) {
|
||||||
|
float phase = pixelX * TAU * CC_PER_PIXEL;
|
||||||
|
phase += pixelY * PI; // 180° per line (from 227.5 cycles/line)
|
||||||
|
phase += frameOffset;
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
float encodeComposite(vec3 rgb, float phase) {
|
||||||
|
vec3 yiq = RGB_TO_YIQ * rgb;
|
||||||
|
return yiq.x + yiq.y * cos(phase) + yiq.z * sin(phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === COMPOSITE SIGNAL DECODE ===
|
||||||
|
vec3 decodeComposite(vec2 uv, vec2 texelSize, float basePhase) {
|
||||||
|
float compFilter = getCompositeFilter();
|
||||||
|
float iFilter = getChromaIFilter();
|
||||||
|
float qFilter = getChromaQFilter();
|
||||||
|
|
||||||
|
float yAccum = 0.0, iAccum = 0.0, qAccum = 0.0;
|
||||||
|
float yWeight = 0.0, iWeight = 0.0, qWeight = 0.0;
|
||||||
|
|
||||||
|
for (int i = -FILTER_RADIUS; i <= FILTER_RADIUS; i++) {
|
||||||
|
float offset = float(i);
|
||||||
|
vec2 sampleUV = uv + vec2(offset * texelSize.x, 0.0);
|
||||||
|
|
||||||
|
vec3 srcRGB = sampleTexture(sampleUV);
|
||||||
|
float samplePhase = basePhase + offset * TAU * CC_PER_PIXEL;
|
||||||
|
float composite = encodeComposite(srcRGB, samplePhase);
|
||||||
|
|
||||||
|
// Low-pass for luma
|
||||||
|
float yw = gaussianWeight(offset, compFilter);
|
||||||
|
yAccum += composite * yw;
|
||||||
|
yWeight += yw;
|
||||||
|
|
||||||
|
// Demodulate and filter chroma
|
||||||
|
float iw = gaussianWeight(offset, iFilter);
|
||||||
|
float qw = gaussianWeight(offset, qFilter);
|
||||||
|
|
||||||
|
iAccum += composite * cos(samplePhase) * 2.0 * iw;
|
||||||
|
qAccum += composite * sin(samplePhase) * 2.0 * qw;
|
||||||
|
|
||||||
|
iWeight += iw;
|
||||||
|
qWeight += qw;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 yiq = vec3(yAccum / yWeight, iAccum / iWeight, qAccum / qWeight);
|
||||||
|
return YIQ_TO_RGB * yiq;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === S-VIDEO SIGNAL DECODE ===
|
||||||
|
vec3 decodeSVideo(vec2 uv, vec2 texelSize, float basePhase) {
|
||||||
|
float yFilter = getLumaFilter();
|
||||||
|
float iFilter = getChromaIFilter();
|
||||||
|
float qFilter = getChromaQFilter();
|
||||||
|
|
||||||
|
float yAccum = 0.0, iAccum = 0.0, qAccum = 0.0;
|
||||||
|
float yWeight = 0.0, iWeight = 0.0, qWeight = 0.0;
|
||||||
|
|
||||||
|
for (int i = -FILTER_RADIUS; i <= FILTER_RADIUS; i++) {
|
||||||
|
float offset = float(i);
|
||||||
|
vec2 sampleUV = uv + vec2(offset * texelSize.x, 0.0);
|
||||||
|
|
||||||
|
vec3 srcRGB = sampleTexture(sampleUV);
|
||||||
|
vec3 yiq = RGB_TO_YIQ * srcRGB;
|
||||||
|
|
||||||
|
float samplePhase = basePhase + offset * TAU * CC_PER_PIXEL;
|
||||||
|
float chromaSignal = yiq.y * cos(samplePhase) + yiq.z * sin(samplePhase);
|
||||||
|
|
||||||
|
// Luma is separate - no cross-color
|
||||||
|
float yw = gaussianWeight(offset, yFilter);
|
||||||
|
yAccum += yiq.x * yw;
|
||||||
|
yWeight += yw;
|
||||||
|
|
||||||
|
// Chroma demodulation
|
||||||
|
float iw = gaussianWeight(offset, iFilter);
|
||||||
|
float qw = gaussianWeight(offset, qFilter);
|
||||||
|
|
||||||
|
iAccum += chromaSignal * cos(samplePhase) * 2.0 * iw;
|
||||||
|
qAccum += chromaSignal * sin(samplePhase) * 2.0 * qw;
|
||||||
|
|
||||||
|
iWeight += iw;
|
||||||
|
qWeight += qw;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 yiqOut = vec3(yAccum / yWeight, iAccum / iWeight, qAccum / qWeight);
|
||||||
|
return YIQ_TO_RGB * yiqOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === CGA COMPOSITE DECODE ===
|
||||||
|
// CGA has exactly 4 pixels per color cycle (14.318 MHz / 3.579545 MHz = 4)
|
||||||
|
// This creates the famous artifact colors from specific bit patterns
|
||||||
|
vec3 decodeCGAComposite(vec2 uv, vec2 texelSize, float pixelX, float pixelY) {
|
||||||
|
// CGA-specific filter widths - slightly different from generic NTSC
|
||||||
|
// CGA monitors typically had less filtering, making artifacts more pronounced
|
||||||
|
float yFilter = 1.2;
|
||||||
|
float chromaFilter = 2.5;
|
||||||
|
|
||||||
|
// CGA color burst phase - this determines the base hue
|
||||||
|
// Adjusted to match the canonical CGA artifact color palette
|
||||||
|
float cgaPhaseOffset = cgaHue + PI * 0.5; // Adjust for correct color alignment
|
||||||
|
|
||||||
|
// CGA doesn't have the 227.5 cycle per line offset in the same way
|
||||||
|
// The phase is more deterministic based on pixel position
|
||||||
|
float basePhase = pixelX * TAU * CGA_CC_PER_PIXEL + cgaPhaseOffset;
|
||||||
|
|
||||||
|
// Odd lines have 180° phase shift (creates the alternating pattern)
|
||||||
|
if (mod(pixelY, 2.0) >= 1.0) {
|
||||||
|
basePhase += PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
float yAccum = 0.0, iAccum = 0.0, qAccum = 0.0;
|
||||||
|
float yWeight = 0.0, chromaWeight = 0.0;
|
||||||
|
|
||||||
|
// Use smaller filter radius for sharper CGA look
|
||||||
|
const int CGA_RADIUS = 8;
|
||||||
|
|
||||||
|
for (int i = -CGA_RADIUS; i <= CGA_RADIUS; i++) {
|
||||||
|
float offset = float(i);
|
||||||
|
vec2 sampleUV = uv + vec2(offset * texelSize.x, 0.0);
|
||||||
|
|
||||||
|
// CGA outputs either black (0) or white (1) in 640x200 mode
|
||||||
|
// Get the source value (treating as monochrome for artifact generation)
|
||||||
|
vec3 srcRGB = sampleTexture(sampleUV);
|
||||||
|
float srcLuma = dot(srcRGB, vec3(0.299, 0.587, 0.114));
|
||||||
|
|
||||||
|
// For CGA artifact colors, we use the luma as the composite signal level
|
||||||
|
// In reality, CGA outputs either 0V or ~0.7V for the two states
|
||||||
|
float composite = srcLuma;
|
||||||
|
|
||||||
|
float samplePhase = basePhase + offset * TAU * CGA_CC_PER_PIXEL;
|
||||||
|
|
||||||
|
// Low-pass filter for luma
|
||||||
|
float yw = gaussianWeight(offset, yFilter);
|
||||||
|
yAccum += composite * yw;
|
||||||
|
yWeight += yw;
|
||||||
|
|
||||||
|
// Demodulate chroma
|
||||||
|
float cw = gaussianWeight(offset, chromaFilter);
|
||||||
|
iAccum += composite * cos(samplePhase) * 2.0 * cw;
|
||||||
|
qAccum += composite * sin(samplePhase) * 2.0 * cw;
|
||||||
|
chromaWeight += cw;
|
||||||
|
}
|
||||||
|
|
||||||
|
float y = yAccum / yWeight;
|
||||||
|
float i = (iAccum / chromaWeight) * getCgaSaturation();
|
||||||
|
float q = (qAccum / chromaWeight) * getCgaSaturation();
|
||||||
|
|
||||||
|
// Convert to RGB
|
||||||
|
vec3 rgb = YIQ_TO_RGB * vec3(y, i, q);
|
||||||
|
|
||||||
|
return rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TRINITRON PHOSPHOR MASK ===
|
||||||
|
vec3 trinitronMask(vec2 screenPos) {
|
||||||
|
float strength = getPhosphorStrength();
|
||||||
|
float outputX = screenPos.x * 2.0; // 2x display scale
|
||||||
|
float stripe = mod(outputX, 3.0);
|
||||||
|
|
||||||
|
float bleed = 0.15;
|
||||||
|
vec3 mask;
|
||||||
|
|
||||||
|
if (stripe < 1.0) {
|
||||||
|
mask = vec3(1.0, bleed, bleed);
|
||||||
|
} else if (stripe < 2.0) {
|
||||||
|
mask = vec3(bleed, 1.0, bleed);
|
||||||
|
} else {
|
||||||
|
mask = vec3(bleed, bleed, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
float compensation = 1.0 / (0.333 + 0.667 * bleed);
|
||||||
|
mask *= compensation * 0.85;
|
||||||
|
|
||||||
|
return mix(vec3(1.0), mask, strength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SCANLINE MASK ===
|
||||||
|
float scanlineMask(vec2 screenPos) {
|
||||||
|
float strength = getScanlineStrength();
|
||||||
|
float outputY = screenPos.y * 2.0; // 2x display scale
|
||||||
|
|
||||||
|
float scanline = sin(outputY * PI);
|
||||||
|
scanline = scanline * 0.5 + 0.5;
|
||||||
|
scanline = pow(scanline, 0.4);
|
||||||
|
|
||||||
|
return mix(1.0 - strength, 1.0, scanline);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MAIN ===
|
||||||
|
void main() {
|
||||||
|
vec2 uv = v_texCoords;
|
||||||
|
uv.x = mix(uv.x, 1.0 - uv.x, flip.x);
|
||||||
|
uv.y = mix(uv.y, 1.0 - uv.y, flip.y);
|
||||||
|
|
||||||
|
vec2 texelSize = 1.0 / resolution;
|
||||||
|
float pixelX = uv.x * resolution.x;
|
||||||
|
float pixelY = uv.y * resolution.y;
|
||||||
|
|
||||||
|
// Frame phase for dot crawl (4-frame cycle)
|
||||||
|
float framePhase = mod(time, 4.0) * PI * 0.5;
|
||||||
|
float basePhase = calcCarrierPhase(pixelX, pixelY, framePhase);
|
||||||
|
|
||||||
|
// Decode signal based on mode
|
||||||
|
vec3 rgb;
|
||||||
|
if (signalMode == 2) {
|
||||||
|
// CGA Composite mode - deterministic artifact colors
|
||||||
|
rgb = decodeCGAComposite(uv, texelSize, pixelX, pixelY);
|
||||||
|
} else if (signalMode == 1) {
|
||||||
|
rgb = decodeComposite(uv, texelSize, basePhase);
|
||||||
|
} else {
|
||||||
|
rgb = decodeSVideo(uv, texelSize, basePhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRT display effects
|
||||||
|
vec2 screenPos = vec2(pixelX, pixelY);
|
||||||
|
// rgb *= trinitronMask(screenPos);
|
||||||
|
// rgb *= scanlineMask(screenPos);
|
||||||
|
|
||||||
|
fragColor = vec4(clamp(rgb, 0.0, 1.0), 1.0);
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
|||||||
camera.update()
|
camera.update()
|
||||||
batch.projectionMatrix = camera.combined
|
batch.projectionMatrix = camera.combined
|
||||||
|
|
||||||
crtShader = loadShaderInline(CRT_POST_SHADER2)
|
crtShader = loadShaderInline(Gdx.files.classpath("net/torvald/tsvm/shader_crt_post.frag").readString())
|
||||||
|
|
||||||
gpuFBO = FrameBuffer(Pixmap.Format.RGBA8888, viewportWidth, viewportHeight, false)
|
gpuFBO = FrameBuffer(Pixmap.Format.RGBA8888, viewportWidth, viewportHeight, false)
|
||||||
winFBO = FrameBuffer(Pixmap.Format.RGBA8888, viewportWidth, viewportHeight, false)
|
winFBO = FrameBuffer(Pixmap.Format.RGBA8888, viewportWidth, viewportHeight, false)
|
||||||
@@ -556,200 +556,3 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
const val CRT_POST_SHADER2 = """
|
|
||||||
#ifdef GL_ES
|
|
||||||
precision mediump float;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
in vec4 v_color;
|
|
||||||
in vec4 v_generic;
|
|
||||||
in vec2 v_texCoords;
|
|
||||||
uniform sampler2D u_texture;
|
|
||||||
uniform vec2 resolution = vec2(640.0, 480.0);
|
|
||||||
out vec4 fragColor;
|
|
||||||
|
|
||||||
uniform float time = 0.0;
|
|
||||||
|
|
||||||
const int SUBS = 6; // horizontal subsamples per pixel (oversampling)
|
|
||||||
const float PI = 3.14159265359;
|
|
||||||
|
|
||||||
// --- RGB <-> YIQ (NTSC-ish) ---
|
|
||||||
vec3 rgb2yiq(vec3 rgb) {
|
|
||||||
float y = dot(rgb, vec3(0.299, 0.587, 0.114));
|
|
||||||
float i = dot(rgb, vec3(0.596, -0.274, -0.322));
|
|
||||||
float q = dot(rgb, vec3(0.211, -0.523, 0.312));
|
|
||||||
return vec3(y, i, q);
|
|
||||||
}
|
|
||||||
|
|
||||||
vec3 yiq2rgb(vec3 yiq) {
|
|
||||||
float y = yiq.x, i = yiq.y, q = yiq.z;
|
|
||||||
vec3 r = vec3(
|
|
||||||
y + 0.956*i + 0.621*q,
|
|
||||||
y - 0.272*i - 0.647*q,
|
|
||||||
y - 1.106*i + 1.703*q
|
|
||||||
);
|
|
||||||
return clamp(r, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Parameters you can tweak ---
|
|
||||||
float subcarrierCyclesPerScanline = 227.5; // NTSC approx cycles per scanline (spatial)
|
|
||||||
float chromaGain = 2.2; // strength of chroma modulation in encode
|
|
||||||
float chromaLPF_radius = 3.6; // lowpass radius in pixels for chroma (larger = more bleed)
|
|
||||||
float lumaLPF_radius = 0.7; // lowpass for luma (small = sharp)
|
|
||||||
float chromaPhaseDrift = 1.1; // extra phase offset (use time to animate dot crawl)
|
|
||||||
float subsampleSpanPixels = 2.0 / 3.0; // how wide the subsample footprint is (in pixels)
|
|
||||||
|
|
||||||
// Simple 1D gaussian weight (not normalized here)
|
|
||||||
float gaussWeight(float x, float r) {
|
|
||||||
return exp(- (x*x) / (2.0 * r * r));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample a tiny neighborhood and get averaged Y/I/Q and also do composite modulation
|
|
||||||
// We oversample horizontally to simulate the analog sampling along the scanline.
|
|
||||||
void compositeAtUV(in vec2 uv, out float avgComposite, out float avgY, out float avgDemodI, out float avgDemodQ) {
|
|
||||||
avgComposite = 0.0;
|
|
||||||
avgY = 0.0;
|
|
||||||
avgDemodI = 0.0;
|
|
||||||
avgDemodQ = 0.0;
|
|
||||||
|
|
||||||
// pixel coordinates
|
|
||||||
float px = uv.x * resolution.x;
|
|
||||||
float py = uv.y * resolution.y;
|
|
||||||
// subcarrier spatial frequency (cycles per pixel horizontally)
|
|
||||||
// cycles per scanline / pixels per scanline = cycles per pixel (approx)
|
|
||||||
float cycles_per_pixel = subcarrierCyclesPerScanline / resolution.x;
|
|
||||||
// time-varying phase for dot-crawl
|
|
||||||
float linePhase = (py * subcarrierCyclesPerScanline) * 2.0 * PI + chromaPhaseDrift * time;
|
|
||||||
|
|
||||||
// do SUBS evenly-spaced subsamples across this pixel horizontally
|
|
||||||
float totalWeight = 0.0;
|
|
||||||
for (int s = 0; s < SUBS; ++s) {
|
|
||||||
float t = (float(s) + 0.5) / float(SUBS) - 0.5; // -0.5 .. +0.5
|
|
||||||
float sx = px + t * subsampleSpanPixels; // sample x in pixel space
|
|
||||||
vec2 sUV = vec2(sx / resolution.x, uv.y);
|
|
||||||
|
|
||||||
vec3 col = texture2D(u_texture, sUV).rgb;
|
|
||||||
vec3 yiq = rgb2yiq(col);
|
|
||||||
float Y = yiq.x;
|
|
||||||
float I = yiq.y;
|
|
||||||
float Q = yiq.z;
|
|
||||||
|
|
||||||
// subcarrier phase at this horizontal sample
|
|
||||||
float phi = linePhase + sx * cycles_per_pixel * 2.0 * PI;
|
|
||||||
|
|
||||||
// composite waveform value (analog mix)
|
|
||||||
float carrierCos = cos(phi);
|
|
||||||
float carrierSin = sin(phi);
|
|
||||||
float composite = Y + chromaGain * (I * carrierCos + Q * carrierSin);
|
|
||||||
|
|
||||||
// demodulation multipliers (we will low-pass by averaging)
|
|
||||||
float demodI = composite * carrierCos;
|
|
||||||
float demodQ = composite * carrierSin;
|
|
||||||
|
|
||||||
// weight: use gaussian centered on pixel center
|
|
||||||
float w = gaussWeight(t * subsampleSpanPixels, 0.6); // narrower weighting
|
|
||||||
totalWeight += w;
|
|
||||||
|
|
||||||
avgComposite += composite * w;
|
|
||||||
avgY += Y * w;
|
|
||||||
avgDemodI += demodI * w;
|
|
||||||
avgDemodQ += demodQ * w;
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalize the local averages (this approximates a basic low-pass)
|
|
||||||
avgComposite /= totalWeight;
|
|
||||||
avgY /= totalWeight;
|
|
||||||
avgDemodI /= totalWeight;
|
|
||||||
avgDemodQ /= totalWeight;
|
|
||||||
|
|
||||||
// --- additional spatial low-pass filtering to mimic channel bandwidth ---
|
|
||||||
// We'll sample neighbours on x and average with Gaussian weights to emulate chroma/luma LPF
|
|
||||||
// Note: we do a tiny neighborhood (±2 pixels) to save performance.
|
|
||||||
float wsumC = 1.0;
|
|
||||||
float wsumY = 1.0;
|
|
||||||
float csum = avgComposite;
|
|
||||||
float idsum = avgDemodI;
|
|
||||||
float qdsum = avgDemodQ;
|
|
||||||
float ysum = avgY;
|
|
||||||
|
|
||||||
// radius in pixels for chroma or luma affects weights below; we'll use same kernel but scale contributions
|
|
||||||
int taps = 2;
|
|
||||||
for (int i = -taps; i <= taps; ++i) {
|
|
||||||
if (i == 0) continue;
|
|
||||||
float off = float(i);
|
|
||||||
// use chroma radius for chroma-related weights, luma radius for luma
|
|
||||||
float weightC = gaussWeight(off, chromaLPF_radius);
|
|
||||||
float weightY = gaussWeight(off, lumaLPF_radius);
|
|
||||||
|
|
||||||
vec2 sampleUV = vec2((px + off) / resolution.x, uv.y);
|
|
||||||
vec3 ncol = texture2D(u_texture, sampleUV).rgb;
|
|
||||||
vec3 nyiq = rgb2yiq(ncol);
|
|
||||||
float nY = nyiq.x;
|
|
||||||
float nI = nyiq.y;
|
|
||||||
float nQ = nyiq.z;
|
|
||||||
|
|
||||||
float nPhi = linePhase + (px + off) * cycles_per_pixel * 2.0 * PI;
|
|
||||||
float nComposite = nY + chromaGain * (nI * cos(nPhi) + nQ * sin(nPhi));
|
|
||||||
float ndemodI = nComposite * cos(nPhi);
|
|
||||||
float ndemodQ = nComposite * sin(nPhi);
|
|
||||||
|
|
||||||
csum += nComposite * weightC;
|
|
||||||
idsum += ndemodI * weightC;
|
|
||||||
qdsum += ndemodQ * weightC;
|
|
||||||
wsumC += weightC;
|
|
||||||
|
|
||||||
ysum += nY * weightY;
|
|
||||||
wsumY += weightY;
|
|
||||||
}
|
|
||||||
|
|
||||||
avgComposite = csum / wsumC;
|
|
||||||
avgY = ysum / wsumY;
|
|
||||||
avgDemodI = idsum / wsumC;
|
|
||||||
avgDemodQ = qdsum / wsumC;
|
|
||||||
}
|
|
||||||
|
|
||||||
// main
|
|
||||||
void mainImage(out vec4 frag_Color, in vec2 frag_Coord) {
|
|
||||||
vec2 uv = frag_Coord.xy / resolution.xy;
|
|
||||||
uv.y = 1.0 - uv.y;
|
|
||||||
|
|
||||||
// Get composite and demod values centered at uv
|
|
||||||
float compositeVal, Yval, demodI, demodQ;
|
|
||||||
compositeAtUV(uv, compositeVal, Yval, demodI, demodQ);
|
|
||||||
|
|
||||||
// Low-pass the demodulators to extract I & Q (simple normalization)
|
|
||||||
// In a real demodulator you'd low-pass filter demod*carrier; here we've already averaged spatially,
|
|
||||||
// so just apply a gain normalization and small smoothing.
|
|
||||||
// Normalize I/Q by average carrier power (~0.5)
|
|
||||||
float carrierPower = 0.5;
|
|
||||||
float I_rec = demodI / max(carrierPower, 1e-5);
|
|
||||||
float Q_rec = demodQ / max(carrierPower, 1e-5);
|
|
||||||
|
|
||||||
// Optionally reduce chroma bandwidth / smear by blurring (simulate narrow chroma filter)
|
|
||||||
// We'll lerp the recovered chroma towards zero based on chromaLPF_radius
|
|
||||||
float chromaBlurFactor = smoothstep(0.0, 5.0, chromaLPF_radius); // 0..1
|
|
||||||
I_rec *= 1.0 - 0.65 * chromaBlurFactor;
|
|
||||||
Q_rec *= 1.0 - 0.65 * chromaBlurFactor;
|
|
||||||
|
|
||||||
// For luma, we can optionally low-pass Yval slightly to simulate limited luma bandwidth
|
|
||||||
// but keep it mostly sharp
|
|
||||||
float Y_lp = Yval; // already lightly filtered above
|
|
||||||
// final reconstructed yiq
|
|
||||||
vec3 yiqRec = vec3(Y_lp, I_rec, Q_rec);
|
|
||||||
vec3 rgbRec = yiq2rgb(yiqRec);
|
|
||||||
|
|
||||||
// Optional: blend with original luminance to keep text legible (tweakable)
|
|
||||||
// vec3 orig = texture2D(u_texture, uv).rgb;
|
|
||||||
// rgbRec = mix(rgbRec, orig, 0.25);
|
|
||||||
|
|
||||||
frag_Color = vec4(rgbRec, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vec2 fragCoord = gl_FragCoord.xy;
|
|
||||||
vec4 outcol;
|
|
||||||
mainImage(outcol, fragCoord);
|
|
||||||
fragColor = outcol;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
Reference in New Issue
Block a user