// 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; const modelUrl = new URLSearchParams(location.search).get('model') || 'suit.glb'; new GLTFLoader().load('assets/' + modelUrl, (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); }); const first = actions['Walk'] ? 'Walk' : (gltf.animations[0] ? gltf.animations[0].name : null); if (first) { play(first); sel.value = first; } 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); })();