renderang v0.1: PS1/N64/Saturn retro renderer — vertex snap, affine warp, low-res framebuffer, color crush + Bayer dither, animated GLTF demo actor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-30 10:13:15 +10:00
commit 38857c7fc1
10 changed files with 62681 additions and 0 deletions

6
.claude/launch.json Normal file
View File

@ -0,0 +1,6 @@
{
"version": "0.0.1",
"configurations": [
{ "name": "renderang", "runtimeExecutable": "python3", "runtimeArgs": ["-m", "http.server", "8930"], "port": 8930 }
]
}

22
README.md Normal file
View File

@ -0,0 +1,22 @@
# renderang
A retro console renderer for the GODVERSE — PS1 / Saturn / N64 looks over three.js.
- **Vertex snapping** (quantized NDC = the PS1 wobble), **affine texture warp**,
low-res internal framebuffer with integer nearest upscale, 15-bit color crush,
4x4 Bayer dither, scanlines, fog. All live-tweakable.
- Works on stock three.js materials via `onBeforeCompile`, so skinned/animated
GLTF characters work untouched — demo actor is a quaternius modular man
(CC0) with his 24 embedded clips.
- No build step: importmap + vendored three (r170). Serve the dir and go:
`python3 -m http.server 8930`
## files
- `src/retro.js` — material patcher (`patchRetroMaterial`, `retrofyObject`) + `RetroScreen` post pipeline
- `src/main.js` — demo scene, mode presets (PS1/N64/SATURN/OFF), UI wiring
- `assets/suit.glb` — demo actor (quaternius, CC0)
## next ideas
- level loader (bring in cosmos/kitbash exports)
- CRT curvature + phosphor mask pass
- MRPGI / web-game integration as a drop-in renderer

BIN
assets/suit.glb Normal file

Binary file not shown.

76
index.html Normal file
View File

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>renderang</title>
<style>
:root { --bg:#14101d; --panel:#241c33; --ink:#cfc3ec; --hot:#ff5c8a; --dim:#6b5f8a; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink);
font-family:"Courier New", monospace; height:100vh; display:flex; overflow:hidden; }
#view { flex:1; display:flex; align-items:center; justify-content:center; background:#000; }
#view canvas { image-rendering: pixelated; }
#panel { width:230px; background:var(--panel); border-left:4px solid #000;
padding:10px; font-size:12px; overflow-y:auto; flex-shrink:0; }
h1 { font-size:15px; margin:0 0 2px; color:var(--hot); letter-spacing:2px; }
.sub { color:var(--dim); margin-bottom:12px; }
fieldset { border:2px solid var(--dim); margin:0 0 10px; padding:6px 8px; }
legend { padding:0 4px; color:var(--hot); }
label { display:block; margin:6px 0 2px; }
input[type=range] { width:100%; accent-color:var(--hot); }
select { width:100%; background:var(--bg); color:var(--ink);
border:2px solid var(--dim); font-family:inherit; padding:2px; }
.modes { display:grid; grid-template-columns:1fr 1fr; gap:4px; }
.modes button { background:var(--bg); color:var(--ink); border:2px solid var(--dim);
padding:5px 0; font-family:inherit; font-size:12px; cursor:pointer; }
.modes button.on { background:var(--hot); color:#14101d; border-color:var(--hot); font-weight:bold; }
.row { display:flex; align-items:center; gap:6px; margin:5px 0; }
.row input { accent-color:var(--hot); }
</style>
</head>
<body>
<div id="view"></div>
<div id="panel">
<h1>RENDERANG</h1>
<div class="sub">retro renderer v0.1</div>
<fieldset>
<legend>mode</legend>
<div class="modes">
<button data-mode="ps1" class="on">PS1</button>
<button data-mode="n64">N64</button>
<button data-mode="saturn">SATURN</button>
<button data-mode="off">OFF</button>
</div>
</fieldset>
<fieldset>
<legend>knobs</legend>
<label>vertex snap <input id="snap" type="range" min="0" max="1" step="0.05" value="1"></label>
<label>affine warp <input id="affine" type="range" min="0" max="1" step="0.05" value="1"></label>
<label>color levels <input id="levels" type="range" min="4" max="256" step="4" value="32"></label>
<div class="row"><input id="dither" type="checkbox" checked><span>bayer dither</span></div>
<div class="row"><input id="scan" type="checkbox"><span>scanlines</span></div>
<label>internal res
<select id="res">
<option>320x240</option>
<option>256x224</option>
<option>160x120</option>
<option>512x384</option>
<option>640x480</option>
</select>
</label>
</fieldset>
<fieldset>
<legend>actor</legend>
<label>animation <select id="anim"></select></label>
</fieldset>
</div>
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js" } }
</script>
<script type="module" src="src/main.js"></script>
</body>
</html>

190
src/main.js Normal file
View File

@ -0,0 +1,190 @@
// renderang demo — a quaternius character in a PS1-flavoured world.
import * as THREE from 'three';
import { GLTFLoader } from '../vendor/GLTFLoader.js';
import { OrbitControls } from '../vendor/OrbitControls.js';
import { RetroScreen, retroState, retrofyObject, patchRetroMaterial } from './retro.js';
const FOG_COLOR = 0x2a2036;
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setPixelRatio(1);
document.getElementById('view').appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(FOG_COLOR);
scene.fog = new THREE.Fog(FOG_COLOR, 6, 26);
const camera = new THREE.PerspectiveCamera(60, 4 / 3, 0.1, 100);
camera.position.set(3.2, 2.2, 4.2);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xcabfe8, 0x4a3c5e, 1.6));
const sun = new THREE.DirectionalLight(0xffeedd, 2.2);
sun.position.set(4, 6, 3);
scene.add(sun);
const screen = new RetroScreen(renderer);
// --- procedural retro textures (canvas, nearest) ------------------------------
function canvasTexture(draw, size = 64) {
const c = document.createElement('canvas');
c.width = c.height = size;
draw(c.getContext('2d'), size);
const t = new THREE.CanvasTexture(c);
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.generateMipmaps = false;
t.wrapS = t.wrapT = THREE.RepeatWrapping;
return t;
}
const checkerTex = canvasTexture((g, s) => {
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
g.fillStyle = (x + y) % 2 ? '#4a3f66' : '#5d5080';
g.fillRect(x * s / 8, y * s / 8, s / 8, s / 8);
}
});
checkerTex.repeat.set(12, 12);
const brickTex = canvasTexture((g, s) => {
g.fillStyle = '#6e5a4e'; g.fillRect(0, 0, s, s);
g.fillStyle = '#8a7263';
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++)
g.fillRect(x * 16 + (y % 2 ? 8 : 0) + 1, y * 16 + 1, 14, 14);
});
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(40, 40),
patchRetroMaterial(new THREE.MeshLambertMaterial({ map: checkerTex }))
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// scatter some chunky geometry so the wobble has something to chew on
const brickMat = patchRetroMaterial(new THREE.MeshLambertMaterial({ map: brickTex }));
const shapes = [];
for (let i = 0; i < 14; i++) {
const kind = i % 3;
const geo = kind === 0 ? new THREE.BoxGeometry(1.4, 1 + (i % 4), 1.4)
: kind === 1 ? new THREE.ConeGeometry(0.9, 1.8, 6)
: new THREE.CylinderGeometry(0.6, 0.6, 1.4, 8);
const m = new THREE.Mesh(geo, brickMat);
const a = (i / 14) * Math.PI * 2;
const r = 6 + (i % 3) * 3;
m.position.set(Math.cos(a) * r, geo.parameters.height ? geo.parameters.height / 2 : 0.7, Math.sin(a) * r);
m.rotation.y = a;
scene.add(m);
shapes.push(m);
}
// --- character ----------------------------------------------------------------
let mixer = null;
let actions = {};
let current = null;
new GLTFLoader().load('assets/suit.glb', (gltf) => {
const char = retrofyObject(gltf.scene);
scene.add(char);
mixer = new THREE.AnimationMixer(char);
const sel = document.getElementById('anim');
gltf.animations
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((clip) => {
actions[clip.name] = mixer.clipAction(clip);
const o = document.createElement('option');
o.value = o.textContent = clip.name;
sel.appendChild(o);
});
play('Walk');
sel.value = 'Walk';
sel.onchange = () => play(sel.value);
});
function play(name) {
if (!actions[name]) return;
if (current) current.fadeOut(0.15);
current = actions[name].reset().fadeIn(0.15).play();
}
// --- modes + UI ---------------------------------------------------------------
const MODES = {
ps1: { res: [320, 240], snap: 1, affine: 1, levels: 32, dither: 1, filter: 'nearest' },
n64: { res: [320, 240], snap: 0, affine: 0, levels: 256, dither: 0.5, filter: 'linear' },
saturn: { res: [352, 240], snap: 1, affine: 1, levels: 16, dither: 1, filter: 'nearest' },
off: { res: null },
};
let retroOn = true;
function setTextureFilter(linear) {
scene.traverse((o) => {
if (o.isMesh && o.material && o.material.map) {
const f = linear ? THREE.LinearFilter : THREE.NearestFilter;
o.material.map.magFilter = f;
o.material.map.minFilter = f;
o.material.map.needsUpdate = true;
}
});
}
function applyMode(name) {
const m = MODES[name];
document.querySelectorAll('[data-mode]').forEach((b) =>
b.classList.toggle('on', b.dataset.mode === name));
if (!m.res) { retroOn = false; resize(); return; }
retroOn = true;
retroState.snap = m.snap;
retroState.affine = m.affine;
retroState.colorLevels = m.levels;
retroState.dither = m.dither;
screen.setResolution(m.res[0], m.res[1]);
setTextureFilter(m.filter === 'linear');
syncSliders();
resize();
}
document.querySelectorAll('[data-mode]').forEach((b) =>
b.addEventListener('click', () => applyMode(b.dataset.mode)));
const $ = (id) => document.getElementById(id);
function syncSliders() {
$('snap').value = retroState.snap;
$('affine').value = retroState.affine;
$('levels').value = retroState.colorLevels;
$('dither').checked = retroState.dither > 0;
$('scan').checked = retroState.scanlines > 0;
}
$('snap').oninput = (e) => (retroState.snap = +e.target.value);
$('affine').oninput = (e) => (retroState.affine = +e.target.value);
$('levels').oninput = (e) => (retroState.colorLevels = +e.target.value);
$('dither').onchange = (e) => (retroState.dither = e.target.checked ? 1 : 0);
$('scan').onchange = (e) => (retroState.scanlines = e.target.checked ? 1 : 0);
$('res').onchange = (e) => {
const [w, h] = e.target.value.split('x').map(Number);
screen.setResolution(w, h);
resize();
};
// --- sizing: integer-scale the internal buffer into the window, letterboxed ----
function resize() {
const el = document.getElementById('view');
const W = el.clientWidth, H = el.clientHeight;
if (!retroOn) {
renderer.setSize(W, H);
camera.aspect = W / H;
} else {
const r = retroState.resolution;
const scale = Math.max(1, Math.floor(Math.min(W / r.x, H / r.y)));
renderer.setSize(r.x * scale, r.y * scale);
camera.aspect = r.x / r.y;
}
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
applyMode('ps1');
const clock = new THREE.Clock();
(function tick() {
requestAnimationFrame(tick);
const dt = clock.getDelta();
if (mixer) mixer.update(dt);
controls.update();
screen.render(scene, camera, retroOn);
})();

193
src/retro.js Normal file
View File

@ -0,0 +1,193 @@
// retro.js — the renderang retro pipeline.
// Two halves:
// 1. patchRetroMaterial(material) — vertex snapping + affine texture mapping,
// injected into stock three.js materials so skinning/animation keep working.
// 2. RetroScreen — renders the scene into a tiny framebuffer, then upscales it
// with color crush, Bayer dither and optional scanlines.
import * as THREE from 'three';
// Shared knobs — mutated live by the UI, read by shaders each frame via uniforms.
export const retroState = {
resolution: new THREE.Vector2(320, 240), // internal framebuffer
snap: 1.0, // 0 = off, 1 = snap to internal pixel grid (PS1 wobble)
affine: 1.0, // 0 = perspective correct, 1 = affine warp (PS1 textures)
colorLevels: 32, // quantization levels per channel (32 = 15-bit PS1 vibe)
dither: 1.0, // 0/1 Bayer dither
scanlines: 0.0, // 0/1 CRT scanlines on the upscale
pixelate: true, // nearest-neighbour upscale
};
const materialUniforms = {
uResolution: { value: retroState.resolution },
uSnap: { value: retroState.snap },
uAffine: { value: retroState.affine },
};
export function updateRetroUniforms() {
materialUniforms.uSnap.value = retroState.snap;
materialUniforms.uAffine.value = retroState.affine;
}
// Inject PS1 vertex behaviour into any built-in material (Lambert recommended).
export function patchRetroMaterial(material) {
material.onBeforeCompile = (shader) => {
shader.uniforms.uResolution = materialUniforms.uResolution;
shader.uniforms.uSnap = materialUniforms.uSnap;
shader.uniforms.uAffine = materialUniforms.uAffine;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', `#include <common>
uniform vec2 uResolution;
uniform float uSnap;
uniform float uAffine;
varying float vAffineW;`)
.replace('#include <project_vertex>', `#include <project_vertex>
{
// PS1 vertex snapping: quantize NDC position to the internal pixel grid.
vec2 grid = uResolution * 0.5;
vec2 ndc = gl_Position.xy / gl_Position.w;
vec2 snapped = floor(ndc * grid + 0.5) / grid;
gl_Position.xy = mix(ndc, snapped, uSnap) * gl_Position.w;
// Affine texturing: pre-multiply UV by w so the GPU's perspective
// correction cancels out, then divide by interpolated w in the fragment.
vAffineW = gl_Position.w;
#ifdef USE_MAP
vMapUv = mix(vMapUv, vMapUv * gl_Position.w, uAffine);
#endif
}`);
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', `#include <common>
uniform float uAffine;
varying float vAffineW;`)
.replace('#include <map_fragment>', `
#ifdef USE_MAP
vec2 retroUv = mix(vMapUv, vMapUv / vAffineW, uAffine);
vec4 sampledDiffuseColor = texture2D(map, retroUv);
diffuseColor *= sampledDiffuseColor;
#endif`);
};
material.needsUpdate = true;
return material;
}
// Swap a GLTF's PBR materials for gouraud-ish Lambert + retro patch, force
// nearest-filtered textures (no mipmaps — PS1 had none).
export function retrofyObject(root) {
root.traverse((obj) => {
if (!obj.isMesh) return;
const src = obj.material;
const mats = Array.isArray(src) ? src : [src];
const out = mats.map((m) => {
const lam = new THREE.MeshLambertMaterial({
map: m.map || null,
color: m.color ? m.color.clone() : new THREE.Color(0xffffff),
transparent: m.transparent || false,
opacity: m.opacity !== undefined ? m.opacity : 1,
side: m.side !== undefined ? m.side : THREE.FrontSide,
});
if (lam.map) {
lam.map.magFilter = THREE.NearestFilter;
lam.map.minFilter = THREE.NearestFilter;
lam.map.generateMipmaps = false;
lam.map.needsUpdate = true;
}
return patchRetroMaterial(lam);
});
obj.material = Array.isArray(src) ? out : out[0];
});
return root;
}
// ---------------------------------------------------------------------------
// RetroScreen: low-res framebuffer + upscale pass (crush/dither/scanlines)
// ---------------------------------------------------------------------------
const POST_FRAG = `
precision highp float;
varying vec2 vUv;
uniform sampler2D tFrame;
uniform vec2 uResolution;
uniform float uLevels;
uniform float uDither;
uniform float uScanlines;
float bayer4(vec2 p) {
const mat4 B = mat4(
0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0) / 16.0;
ivec2 i = ivec2(mod(p, 4.0));
return B[i.x][i.y];
}
void main() {
vec3 c = texture2D(tFrame, vUv).rgb;
// Bayer dither in internal-pixel space, then crush to uLevels per channel.
vec2 ip = floor(vUv * uResolution);
float d = (bayer4(ip) - 0.5) * (uDither / uLevels);
c = floor((c + d) * uLevels + 0.5) / uLevels;
// CRT scanlines on output lines.
float scan = 1.0 - uScanlines * 0.25 * mod(floor(gl_FragCoord.y), 2.0);
gl_FragColor = vec4(c * scan, 1.0);
}`;
const POST_VERT = `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
export class RetroScreen {
constructor(renderer) {
this.renderer = renderer;
this.target = null;
this._makeTarget();
this.postScene = new THREE.Scene();
this.postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
this.postMat = new THREE.ShaderMaterial({
vertexShader: POST_VERT,
fragmentShader: POST_FRAG,
uniforms: {
tFrame: { value: null },
uResolution: { value: retroState.resolution },
uLevels: { value: retroState.colorLevels },
uDither: { value: retroState.dither },
uScanlines: { value: retroState.scanlines },
},
depthTest: false,
depthWrite: false,
});
this.postScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.postMat));
}
_makeTarget() {
if (this.target) this.target.dispose();
const r = retroState.resolution;
this.target = new THREE.WebGLRenderTarget(r.x, r.y, {
magFilter: retroState.pixelate ? THREE.NearestFilter : THREE.LinearFilter,
minFilter: retroState.pixelate ? THREE.NearestFilter : THREE.LinearFilter,
depthBuffer: true,
});
}
setResolution(w, h) {
retroState.resolution.set(w, h);
this._makeTarget();
}
render(scene, camera, retroOn) {
updateRetroUniforms();
if (!retroOn) {
this.renderer.setRenderTarget(null);
this.renderer.render(scene, camera);
return;
}
this.postMat.uniforms.uLevels.value = retroState.colorLevels;
this.postMat.uniforms.uDither.value = retroState.dither;
this.postMat.uniforms.uScanlines.value = retroState.scanlines;
this.renderer.setRenderTarget(this.target);
this.renderer.render(scene, camera);
this.postMat.uniforms.tFrame.value = this.target.texture;
this.renderer.setRenderTarget(null);
this.renderer.render(this.postScene, this.postCam);
}
}

1373
vendor/BufferGeometryUtils.js vendored Normal file

File diff suppressed because it is too large Load Diff

4727
vendor/GLTFLoader.js vendored Normal file

File diff suppressed because it is too large Load Diff

1523
vendor/OrbitControls.js vendored Normal file

File diff suppressed because it is too large Load Diff

54571
vendor/three.module.js vendored Normal file

File diff suppressed because one or more lines are too long