renderang/src/main.js

191 lines
6.5 KiB
JavaScript

// 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);
})();