191 lines
6.5 KiB
JavaScript
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);
|
|
})();
|