// 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 = 0x8d84a8; 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, 14, 55); const camera = new THREE.PerspectiveCamera(60, 4 / 3, 0.1, 100); camera.position.set(6.5, 3.2, 9.5); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 1, 0); controls.enableDamping = true; scene.add(new THREE.HemisphereLight(0xe8e2f5, 0x8a7a95, 2.4)); const sun = new THREE.DirectionalLight(0xfff2dd, 3.2); sun.position.set(4, 6, 3); scene.add(sun); const screen = new RetroScreen(renderer); // --- downtown block (paid Downtown City MegaKit, texture-crushed) -------------- const cityLoader = new GLTFLoader(); function loadPiece(name) { return new Promise((res, rej) => cityLoader.load('assets/city/' + name + '.glb', (g) => { retrofyObject(g.scene); res(g.scene); }, undefined, rej)); } function sizeOf(obj) { const b = new THREE.Box3().setFromObject(obj); return { size: b.getSize(new THREE.Vector3()), min: b.min, max: b.max }; } function put(proto, x, z, ry = 0) { const c = proto.clone(true); c.position.set(x, 0, z); c.rotation.y = ry; scene.add(c); return c; } // dark asphalt base plane so there are no holes at the fog line const base = new THREE.Mesh( new THREE.PlaneGeometry(120, 120), patchRetroMaterial(new THREE.MeshLambertMaterial({ color: 0x2b2b30 })) ); base.rotation.x = -Math.PI / 2; base.position.y = -0.03; scene.add(base); (async () => { try { const [street, walk, bSmall, bMed, bLarge, bollard, planter, taxi, car, cop] = await Promise.all([ loadPiece('Street_2Lane'), loadPiece('Sidewalk_Straight_3m'), loadPiece('Building_Small_1'), loadPiece('Building_Medium_2_001'), loadPiece('Building_Large_2'), loadPiece('Prop_Bollard'), loadPiece('Prop_Planter_Single'), loadPiece('Taxi'), loadPiece('NormalCar1'), loadPiece('Cop'), ]); const st = sizeOf(street); const stepX = st.size.x || 3; // tile the road along X const roadHalf = (st.size.z || 9) / 2; for (let i = -5; i <= 5; i++) put(street, i * stepX, 0); const wk = sizeOf(walk); const wkStep = wk.size.x || 3; for (let i = -10; i <= 10; i++) { put(walk, i * wkStep, roadHalf + wk.size.z / 2); put(walk, i * wkStep, -(roadHalf + wk.size.z / 2), Math.PI); } // buildings lining both sides, fronts to the road const row = roadHalf + wk.size.z + 0.2; const north = [bSmall, bMed, bLarge, bSmall]; let x = -22; for (const b of north) { const bs = sizeOf(b); put(b, x + bs.size.x / 2, row + bs.size.z / 2, Math.PI); x += bs.size.x + 1.2; } const south = [bLarge, bSmall, bMed]; x = -18; for (const b of south) { const bs = sizeOf(b); put(b, x + bs.size.x / 2, -(row + bs.size.z / 2)); x += bs.size.x + 1.5; } // kerbside cars + props put(taxi, -4, roadHalf - 1.2, Math.PI); put(car, 5, roadHalf - 1.2, Math.PI); put(cop, 10, -(roadHalf - 1.2)); for (let i = -3; i <= 3; i++) put(bollard, i * 6 + 1.5, roadHalf + 0.6); put(planter, -8, roadHalf + 1.4); put(planter, 8, roadHalf + 1.4); } catch (e) { console.error('city load failed', e); } })(); // --- 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); })();