shellquest
Version:
Terminal-based procedurally generated dungeon crawler
391 lines (341 loc) • 13.1 kB
text/typescript
/**
* LightingRenderer - Applies lighting effects to the final rendered output
*
* Works by modifying the pixel data after all layers have been composited,
* darkening pixels based on a light map computed by the LightingSystem.
*
* Features:
* - Banding: Quantizes brightness into discrete levels for retro look
* - Dithering: Applies ordered dithering at band transitions
* - Warm tinting: Applies orange/amber color shift to lit areas
*/
import type {PixelCanvas} from '../drawing/pixelCanvas.ts';
import type {LightingSystem, LightingConfig} from './LightingSystem.ts';
// =============================================================================
// DITHERING PATTERNS
// =============================================================================
// 4x4 Bayer matrix for ordered dithering (normalized to 0-1)
const BAYER_4X4 = [
[0 / 16, 8 / 16, 2 / 16, 10 / 16],
[12 / 16, 4 / 16, 14 / 16, 6 / 16],
[3 / 16, 11 / 16, 1 / 16, 9 / 16],
[15 / 16, 7 / 16, 13 / 16, 5 / 16],
];
// 8x8 Bayer matrix for finer dithering
const BAYER_8X8 = [
[0 / 64, 32 / 64, 8 / 64, 40 / 64, 2 / 64, 34 / 64, 10 / 64, 42 / 64],
[48 / 64, 16 / 64, 56 / 64, 24 / 64, 50 / 64, 18 / 64, 58 / 64, 26 / 64],
[12 / 64, 44 / 64, 4 / 64, 36 / 64, 14 / 64, 46 / 64, 6 / 64, 38 / 64],
[60 / 64, 28 / 64, 52 / 64, 20 / 64, 62 / 64, 30 / 64, 54 / 64, 22 / 64],
[3 / 64, 35 / 64, 11 / 64, 43 / 64, 1 / 64, 33 / 64, 9 / 64, 41 / 64],
[51 / 64, 19 / 64, 59 / 64, 27 / 64, 49 / 64, 17 / 64, 57 / 64, 25 / 64],
[15 / 64, 47 / 64, 7 / 64, 39 / 64, 13 / 64, 45 / 64, 5 / 64, 37 / 64],
[63 / 64, 31 / 64, 55 / 64, 23 / 64, 61 / 64, 29 / 64, 53 / 64, 21 / 64],
];
/**
* Get dither threshold for a pixel position using 8x8 Bayer matrix
*/
function getDitherThreshold(x: number, y: number): number {
return BAYER_8X8[y & 7][x & 7];
}
// =============================================================================
// BANDING HELPERS
// =============================================================================
/**
* Quantize a brightness value into discrete bands
*/
function quantizeBrightness(brightness: number, bandCount: number): number {
if (brightness <= 0) return 0;
if (brightness >= 1) return 1;
// Quantize to bands
const band = Math.floor(brightness * bandCount);
return band / (bandCount - 1);
}
/**
* Apply dithering to smooth band transitions
* Returns the final quantized brightness with dithering applied
*/
function applyDitheredBanding(
brightness: number,
x: number,
y: number,
bandCount: number,
ditherStrength: number,
): number {
if (brightness <= 0) return 0;
if (brightness >= 1) return 1;
// Get the current and next band levels
const scaledBrightness = brightness * (bandCount - 1);
const lowerBand = Math.floor(scaledBrightness);
const upperBand = Math.min(lowerBand + 1, bandCount - 1);
// How far between bands (0-1)
const bandFraction = scaledBrightness - lowerBand;
// Get dither threshold for this pixel
const threshold = getDitherThreshold(x, y);
// Apply dithering at band edges
// The dither strength controls how wide the dithered transition zone is
const ditherZone = ditherStrength;
let selectedBand: number;
if (bandFraction < 0.5 - ditherZone / 2) {
// Firmly in lower band
selectedBand = lowerBand;
} else if (bandFraction > 0.5 + ditherZone / 2) {
// Firmly in upper band
selectedBand = upperBand;
} else {
// In the dither zone - use threshold to decide
const normalizedFraction = (bandFraction - (0.5 - ditherZone / 2)) / ditherZone;
selectedBand = normalizedFraction > threshold ? upperBand : lowerBand;
}
return selectedBand / (bandCount - 1);
}
/**
* Apply custom threshold-based banding with dithering
* thresholds array defines brightness cutoffs (descending order)
* e.g., [0.7, 0.5] for 3 bands: >0.7 = bright, 0.5-0.7 = mid, <0.5 = dark
*/
function applyThresholdBanding(
brightness: number,
x: number,
y: number,
thresholds: number[],
ditherStrength: number,
bandBrightness: number[], // brightness value for each band
): number {
if (brightness >= 1) return 1;
// Find which band this brightness falls into
let bandIndex = thresholds.length; // Default to darkest band
for (let i = 0; i < thresholds.length; i++) {
if (brightness >= thresholds[i]) {
bandIndex = i;
break;
}
}
// Get the brightness values for current and adjacent bands
const currentBandBrightness = bandBrightness[bandIndex];
// Check if we're near a threshold for dithering
const threshold = getDitherThreshold(x, y);
const ditherRange = ditherStrength * 0.15; // How close to threshold to apply dithering
for (let i = 0; i < thresholds.length; i++) {
const diff = brightness - thresholds[i];
if (Math.abs(diff) < ditherRange) {
// Near this threshold - apply dithering
const normalizedDiff = (diff + ditherRange) / (2 * ditherRange);
if (normalizedDiff > threshold) {
return bandBrightness[i]; // Brighter band
} else {
return bandBrightness[i + 1]; // Darker band
}
}
}
return currentBandBrightness;
}
// =============================================================================
// LIGHTING CONFIG FOR RENDERER
// =============================================================================
export interface LightingRenderConfig {
warmTint: boolean;
bandingEnabled: boolean;
bandCount: number;
ditherEnabled: boolean;
ditherStrength: number;
// Custom band thresholds - brightness values (0-1) where bands transition
// For 3 bands: [0.6, 0.2] means bright band > 0.6, mid band 0.2-0.6, dark band < 0.2
// If not provided, bands are evenly distributed
bandThresholds?: number[];
}
const DEFAULT_RENDER_CONFIG: LightingRenderConfig = {
warmTint: true,
bandingEnabled: true,
bandCount: 3,
ditherEnabled: true,
ditherStrength: 0.1,
bandThresholds: [0.6, 0.5],
};
/**
* Apply lighting to a PixelCanvas
*
* @param canvas The canvas to modify (should have all layers composited)
* @param lightMap RGB brightness values (0-1) for each pixel
* @param config Rendering configuration for banding, dithering, tinting
*/
export function applyLighting(
canvas: PixelCanvas,
lightMap: {r: Float32Array; g: Float32Array; b: Float32Array},
config: Partial<LightingRenderConfig> = {},
): void {
const cfg = {...DEFAULT_RENDER_CONFIG, ...config};
const width = canvas.width;
const height = canvas.height;
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const idx = py * width + px;
let brightnessR = lightMap.r[idx] ?? 0;
let brightnessG = lightMap.g[idx] ?? 0;
let brightnessB = lightMap.b[idx] ?? 0;
// Apply banding and dithering if enabled (apply to each channel)
if (cfg.bandingEnabled) {
if (cfg.bandThresholds && cfg.bandThresholds.length > 0) {
const bandBrightness = [1.0, 0.5, 0.15];
brightnessR = applyThresholdBanding(
brightnessR,
px,
py,
cfg.bandThresholds,
cfg.ditherStrength,
bandBrightness,
);
brightnessG = applyThresholdBanding(
brightnessG,
px,
py,
cfg.bandThresholds,
cfg.ditherStrength,
bandBrightness,
);
brightnessB = applyThresholdBanding(
brightnessB,
px,
py,
cfg.bandThresholds,
cfg.ditherStrength,
bandBrightness,
);
} else if (cfg.ditherEnabled) {
brightnessR = applyDitheredBanding(brightnessR, px, py, cfg.bandCount, cfg.ditherStrength);
brightnessG = applyDitheredBanding(brightnessG, px, py, cfg.bandCount, cfg.ditherStrength);
brightnessB = applyDitheredBanding(brightnessB, px, py, cfg.bandCount, cfg.ditherStrength);
} else {
brightnessR = quantizeBrightness(brightnessR, cfg.bandCount);
brightnessG = quantizeBrightness(brightnessG, cfg.bandCount);
brightnessB = quantizeBrightness(brightnessB, cfg.bandCount);
}
}
// Skip fully lit pixels (all channels at max)
if (brightnessR >= 1.0 && brightnessG >= 1.0 && brightnessB >= 1.0) continue;
// Get current pixel color
const [r, g, b, a] = canvas.getPixel(px, py);
// Skip transparent pixels
if (a === 0) continue;
// Apply per-channel brightness (colored lighting!)
let newR: number;
let newG: number;
let newB: number;
if (cfg.warmTint) {
// Warm tint modifier on top of colored light
const avgBrightness = (brightnessR + brightnessG + brightnessB) / 3;
const darkness = 1 - avgBrightness;
const redMult = 1.0 + darkness * 0.2;
const greenMult = 1.0 - darkness * 0.15;
const blueMult = 1.0 - darkness * 0.4;
newR = Math.round(r * brightnessR * Math.min(redMult, 1.2));
newG = Math.round(g * brightnessG * Math.max(greenMult, 0.6));
newB = Math.round(b * brightnessB * Math.max(blueMult, 0.3));
} else {
// Direct colored lighting - each channel applies its own brightness
newR = Math.round(r * brightnessR);
newG = Math.round(g * brightnessG);
newB = Math.round(b * brightnessB);
}
// Clamp values and set pixel directly
setPixelDirect(canvas, px, py, newR, newG, newB, a);
}
}
}
/**
* Set a pixel directly on the canvas (bypasses translation)
* This is a workaround that accesses the internal pixel buffer
*/
function setPixelDirect(
canvas: PixelCanvas,
x: number,
y: number,
r: number,
g: number,
b: number,
a: number,
): void {
// Access the internal pixels array through the public interface
// We use a typed approach that works with the existing canvas
const currentColor = canvas.getPixel(x, y);
if (currentColor[3] === 0) return; // Skip transparent
// Use save/restore to temporarily reset translation
canvas.save();
canvas.resetTransform();
// Create a temporary RGBA-like object for setPixel
// This works because setPixel accepts an RGBA parameter
const color = {
r: Math.max(0, Math.min(255, r)),
g: Math.max(0, Math.min(255, g)),
b: Math.max(0, Math.min(255, b)),
a: Math.max(0, Math.min(255, a)),
};
canvas.setPixel(x, y, color as any);
canvas.restore();
}
/**
* Compute and apply lighting in one step
*/
export function renderWithLighting(
canvas: PixelCanvas,
lightingSystem: LightingSystem,
cameraX: number,
cameraY: number,
config: Partial<LightingRenderConfig> = {},
): void {
const lightMap = lightingSystem.computeLightMap(canvas.width, canvas.height, cameraX, cameraY);
// Merge lighting system config with render config
const systemConfig = lightingSystem.getConfig();
const mergedConfig: Partial<LightingRenderConfig> = {
bandingEnabled: systemConfig.bandingEnabled,
bandCount: systemConfig.bandCount,
ditherEnabled: systemConfig.ditherEnabled,
ditherStrength: systemConfig.ditherStrength,
...config,
};
applyLighting(canvas, lightMap, mergedConfig);
}
/**
* Apply a simple darkness overlay without a full light map
* Useful for testing or performance-constrained scenarios
*/
export function applySimpleDarkness(
canvas: PixelCanvas,
ambientLevel: number,
playerX: number,
playerY: number,
lightRadius: number,
cameraX: number,
cameraY: number,
): void {
const width = canvas.width;
const height = canvas.height;
// Player position in screen coordinates
const playerScreenX = playerX - cameraX;
const playerScreenY = playerY - cameraY;
const radiusSq = lightRadius * lightRadius;
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
// Calculate distance from player
const dx = px - playerScreenX;
const dy = py - playerScreenY;
const distSq = dx * dx + dy * dy;
let brightness: number;
if (distSq <= radiusSq) {
// Within light radius
const dist = Math.sqrt(distSq);
const falloff = Math.pow(1 - dist / lightRadius, 1.5);
brightness = ambientLevel + (1 - ambientLevel) * falloff;
} else {
brightness = ambientLevel;
}
if (brightness >= 1.0) continue;
const [r, g, b, a] = canvas.getPixel(px, py);
if (a === 0) continue;
const newR = Math.round(r * brightness);
const newG = Math.round(g * brightness);
const newB = Math.round(b * brightness);
setPixelDirect(canvas, px, py, newR, newG, newB, a);
}
}
}