v0.4.5: tegelvloer met witte rand i.p.v. gras, Lucht geen/dag/nacht, Render met daglicht + lampen
- core/skybox.js: tegelvloer (tiles.jpg) passend om het object, rand 100x25 mm wit - nacht-panorama; nacht = 15% omgevingslicht + lampen 100% - Render-knop met camera-icoon: echte lampen in het gekozen licht - gras verwijderd (bestanden lokaal bewaard, uit git) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9ed9cee2e0
commit
7ae76fe390
+121
-81
@@ -5,11 +5,12 @@
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/skybox.js — omgeving voor de 3D-weergaven ("Lucht"-knop): een
|
||||
// panorama (assets/skybox.webp) dat op de grond geprojecteerd wordt, zodat
|
||||
// het object echt op het gras staat, plus scherp gras (assets/grass.jpg) in
|
||||
// een cirkel rond het object dat naar buiten toe overvloeit in het gras van
|
||||
// het panorama. Het gras vangt ook de schaduw op.
|
||||
// core/skybox.js — omgeving voor de 3D-weergaven ("Lucht": geen/dag/nacht).
|
||||
// Een panorama (assets/skybox.webp of skybox-night.webp) wordt op de grond
|
||||
// geprojecteerd, zodat het object op de grond staat; onder het object ligt
|
||||
// een ronde tegelvloer (assets/tiles.jpg) met een witte opstaande rand, groot
|
||||
// genoeg voor het hele object. Vloer en rand reageren op het licht en vangen
|
||||
// de schaduw op. Nacht: omgevingslicht op NIGHT_FACTOR (lampen blijven 100%).
|
||||
//
|
||||
// Grondprojectie: port van three.js GroundedSkybox (r157+, MIT; wij draaien
|
||||
// r128): een binnenstebuiten bol waarvan de onderhelft plat gedrukt wordt op
|
||||
@@ -19,33 +20,25 @@
|
||||
// ============================================================
|
||||
|
||||
window.Skybox = (() => {
|
||||
const PREF_KEY = 'tectum.skybox';
|
||||
const SKY_URL = 'assets/skybox.webp';
|
||||
const GRASS_URL = 'assets/grass.jpg';
|
||||
const CAMERA_HEIGHT_M = 1.7; // hoogte van de panoramacamera boven het gras
|
||||
const PREF_KEY = 'tectum.sky';
|
||||
const MODES = ['none', 'day', 'night'];
|
||||
const SKY_URLS = { day: 'assets/skybox.webp', night: 'assets/skybox-night.webp' };
|
||||
const TILES_URL = 'assets/tiles.jpg';
|
||||
const CAMERA_HEIGHT_M = 1.7; // hoogte van de panoramacamera boven de grond
|
||||
const RADIUS_M = 60; // straal van de omgevingsbol
|
||||
const GRASS_RADIUS_M = 12; // tot hier is het eigen gras volledig zichtbaar
|
||||
const GRASS_FADE_M = 6; // daarna vloeit het over in het panorama
|
||||
const GRASS_TILE_M = 2.0; // breedte van één grastegel (hoogte volgt de beeldverhouding)
|
||||
// assets/grass.jpg is kleurgecorrigeerd naar het gemiddelde grasgroen van het
|
||||
// panorama recht onder de camera (bron: "grass (kopie).jpg"). Het panorama
|
||||
// is onbelicht; het gras wordt belicht door ambient (0.6) + zon (0.8, vanuit
|
||||
// SUN_OFFSET). Deze factor maakt belicht gras gelijk aan de textuurkleur.
|
||||
const TILE_M = 2.4; // één tegeltextuur (1024 px) beslaat zoveel meter
|
||||
const MARGIN_M = 1.5; // vrije rand tussen object en opstaande rand
|
||||
const RIM_HEIGHT_M = 0.10, RIM_THICK_M = 0.025;
|
||||
const NIGHT_FACTOR = 0.15; // omgevingslicht 's nachts
|
||||
const SUN_OFFSET = { x: 8, y: 14, z: 6 };
|
||||
const GRASS_SHADE = 1 / (0.6 + 0.8 * SUN_OFFSET.y / Math.hypot(SUN_OFFSET.x, SUN_OFFSET.y, SUN_OFFSET.z));
|
||||
|
||||
let loading = null;
|
||||
function loadTextures() {
|
||||
if (loading) return loading;
|
||||
const loader = new THREE.TextureLoader();
|
||||
const load = (url) => new Promise((resolve, reject) => loader.load(url, resolve, undefined, reject));
|
||||
loading = Promise.all([load(SKY_URL), load(GRASS_URL)]).then(([sky, grass]) => {
|
||||
grass.wrapS = grass.wrapT = THREE.RepeatWrapping;
|
||||
grass.anisotropy = 8;
|
||||
return { sky, grass };
|
||||
});
|
||||
loading.catch(() => { loading = null; });
|
||||
return loading;
|
||||
const cache = {};
|
||||
function loadTexture(url) {
|
||||
if (!cache[url]) {
|
||||
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject));
|
||||
cache[url].catch(() => { delete cache[url]; });
|
||||
}
|
||||
return cache[url];
|
||||
}
|
||||
|
||||
function buildSky(map) {
|
||||
@@ -65,98 +58,145 @@ window.Skybox = (() => {
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map, depthWrite: false }));
|
||||
mesh.position.y = h - 0.01; // vlakke vloer net onder het eigen gras
|
||||
mesh.position.y = h - 0.01; // vlakke vloer net onder de tegelvloer
|
||||
mesh.renderOrder = -1;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function buildGrass(map) {
|
||||
const R = GRASS_RADIUS_M + GRASS_FADE_M;
|
||||
// Ronde tegelvloer (straal r) + witte opstaande rand erlangs.
|
||||
function buildTerrace(map, r) {
|
||||
const group = new THREE.Group();
|
||||
const tex = map.clone();
|
||||
tex.needsUpdate = true;
|
||||
const aspect = (map.image && map.image.width) ? map.image.height / map.image.width : 1;
|
||||
tex.repeat.set(2 * R / GRASS_TILE_M, 2 * R / (GRASS_TILE_M * aspect));
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
map: tex, color: new THREE.Color(GRASS_SHADE, GRASS_SHADE, GRASS_SHADE), transparent: true, depthWrite: false
|
||||
});
|
||||
// Randvervaging op afstand tot het midden (eigen varying: de UV's zijn
|
||||
// al herhaald voor de textuur).
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.vertexShader = 'varying float vGrassR;\n' + shader.vertexShader.replace(
|
||||
'#include <begin_vertex>', '#include <begin_vertex>\nvGrassR = length(transformed.xy);');
|
||||
shader.fragmentShader = 'varying float vGrassR;\n' + shader.fragmentShader.replace(
|
||||
'#include <dithering_fragment>',
|
||||
`#include <dithering_fragment>\ngl_FragColor.a *= 1.0 - smoothstep(${GRASS_RADIUS_M.toFixed(1)}, ${R.toFixed(1)}, vGrassR);`);
|
||||
};
|
||||
const mesh = new THREE.Mesh(new THREE.CircleGeometry(R, 96), material);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.anisotropy = 8;
|
||||
tex.repeat.set(2 * r / TILE_M, 2 * r / TILE_M);
|
||||
const floor = new THREE.Mesh(new THREE.CircleGeometry(r, 128), new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85 }));
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
group.add(floor);
|
||||
|
||||
const ring = new THREE.Shape();
|
||||
ring.absarc(0, 0, r + RIM_THICK_M, 0, Math.PI * 2, false);
|
||||
const hole = new THREE.Path();
|
||||
hole.absarc(0, 0, r, 0, Math.PI * 2, true);
|
||||
ring.holes.push(hole);
|
||||
const rim = new THREE.Mesh(
|
||||
new THREE.ExtrudeGeometry(ring, { depth: RIM_HEIGHT_M, bevelEnabled: false, curveSegments: 128 }),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 })
|
||||
);
|
||||
rim.rotation.x = -Math.PI / 2; // extrusie omhoog
|
||||
rim.castShadow = rim.receiveShadow = true;
|
||||
group.add(rim);
|
||||
return group;
|
||||
}
|
||||
|
||||
// Omgeving voor één scene. Geeft { group, setPlacement(x, groundY, z) }:
|
||||
// het midden (m) waar het object staat en de hoogte van het maaiveld (m).
|
||||
// Omgeving voor één scene: { group, setMode(mode), fitTo(object3D, groundY) }.
|
||||
function createEnvironment(scene) {
|
||||
const group = new THREE.Group();
|
||||
group.visible = false;
|
||||
scene.add(group);
|
||||
loadTextures().then(({ sky, grass }) => {
|
||||
group.add(buildSky(sky));
|
||||
group.add(buildGrass(grass));
|
||||
}).catch(() => console.warn('Tectum: lucht/gras niet geladen (draait de app via een webserver?)'));
|
||||
const skies = {};
|
||||
let terrace = null, terraceR = 0, tilesTex = null, mode = 'none';
|
||||
|
||||
function showSky() {
|
||||
Object.keys(skies).forEach(k => { skies[k].visible = k === mode; });
|
||||
if (mode === 'none' || skies[mode]) return;
|
||||
const wanted = mode;
|
||||
loadTexture(SKY_URLS[wanted]).then(tex => {
|
||||
if (!skies[wanted]) { skies[wanted] = buildSky(tex); group.add(skies[wanted]); }
|
||||
Object.keys(skies).forEach(k => { skies[k].visible = k === mode; });
|
||||
}).catch(() => console.warn('Tectum: lucht niet geladen (draait de app via een webserver?)'));
|
||||
}
|
||||
function setTerrace(r) {
|
||||
if (terrace && Math.abs(r - terraceR) < 0.05) return;
|
||||
terraceR = r;
|
||||
const build = () => {
|
||||
if (terrace) { group.remove(terrace); terrace.traverse(o => { if (o.geometry) o.geometry.dispose(); }); }
|
||||
terrace = buildTerrace(tilesTex, terraceR);
|
||||
group.add(terrace);
|
||||
};
|
||||
if (tilesTex) build();
|
||||
else loadTexture(TILES_URL).then(t => { tilesTex = t; build(); }).catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
setPlacement(x, groundY, z) { group.position.set(x, groundY, z); }
|
||||
setMode(m) { mode = m; group.visible = m !== 'none'; showSky(); },
|
||||
// Midden en straal uit de omhullende van het object (horizontaal), vloer op groundY (m).
|
||||
fitTo(object3D, groundY) {
|
||||
if (!object3D) return;
|
||||
const box = new THREE.Box3().setFromObject(object3D);
|
||||
if (box.isEmpty()) return;
|
||||
const c = box.getCenter(new THREE.Vector3());
|
||||
group.position.set(c.x, groundY, c.z);
|
||||
setTerrace(Math.hypot(box.max.x - box.min.x, box.max.z - box.min.z) / 2 + MARGIN_M);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isOn() {
|
||||
try { return localStorage.getItem(PREF_KEY) === '1'; } catch (e) { return false; }
|
||||
function getMode() {
|
||||
try {
|
||||
const v = localStorage.getItem(PREF_KEY);
|
||||
if (MODES.includes(v)) return v;
|
||||
return localStorage.getItem('tectum.skybox') === '1' ? 'day' : 'none'; // oude aan/uit-voorkeur
|
||||
} catch (e) { return 'none'; }
|
||||
}
|
||||
function remember(on) {
|
||||
try { localStorage.setItem(PREF_KEY, on ? '1' : '0'); } catch (e) { /* geen opslag: alleen deze sessie */ }
|
||||
function remember(m) {
|
||||
try { localStorage.setItem(PREF_KEY, m); } catch (e) { /* geen opslag: alleen deze sessie */ }
|
||||
}
|
||||
|
||||
// Lucht aan/uit voor een weergave: omgeving tonen, raster verbergen, camera
|
||||
// niet onder de grond, zon met schaduw op het gras.
|
||||
// o: { env, grid, controls, sun, shadowRoot (Object3D die schaduw werpt) }
|
||||
function apply(on, o) {
|
||||
o.env.group.visible = on;
|
||||
const lightFactor = (m) => m === 'night' ? NIGHT_FACTOR : 1;
|
||||
|
||||
// Omgeving toepassen: lucht/vloer, raster, camera niet onder de grond, zon
|
||||
// met schaduw, en het licht (nacht: NIGHT_FACTOR).
|
||||
// o: { env, grid, controls, sun, ambient, base: { ambient, sun }, shadowRoot }
|
||||
function apply(m, o) {
|
||||
const on = m !== 'none';
|
||||
o.env.setMode(m);
|
||||
if (o.grid) o.grid.visible = !on;
|
||||
if (o.controls) o.controls.maxPolarAngle = on ? Math.PI / 2 - 0.03 : Math.PI - 0.001;
|
||||
const f = lightFactor(m);
|
||||
if (o.ambient && o.base) o.ambient.intensity = o.base.ambient * f;
|
||||
if (o.sun && o.base) o.sun.intensity = o.base.sun * f;
|
||||
if (o.sun) {
|
||||
o.sun.castShadow = on;
|
||||
if (on) {
|
||||
const cam = o.sun.shadow.camera;
|
||||
cam.left = cam.bottom = -GRASS_RADIUS_M; cam.right = cam.top = GRASS_RADIUS_M;
|
||||
cam.near = 0.5; cam.far = 60;
|
||||
const p = o.env.group.position, cam = o.sun.shadow.camera;
|
||||
cam.left = cam.bottom = -20; cam.right = cam.top = 20;
|
||||
cam.near = 0.5; cam.far = 80;
|
||||
cam.updateProjectionMatrix();
|
||||
o.sun.shadow.mapSize.set(2048, 2048);
|
||||
o.sun.shadow.bias = -0.0005;
|
||||
o.sun.position.set(o.env.group.position.x + SUN_OFFSET.x, o.env.group.position.y + SUN_OFFSET.y, o.env.group.position.z + SUN_OFFSET.z);
|
||||
o.sun.target.position.copy(o.env.group.position);
|
||||
o.sun.position.set(p.x + SUN_OFFSET.x, p.y + SUN_OFFSET.y, p.z + SUN_OFFSET.z);
|
||||
o.sun.target.position.copy(p);
|
||||
o.sun.target.updateMatrixWorld();
|
||||
}
|
||||
}
|
||||
if (o.shadowRoot) o.shadowRoot.traverse(m => { if (m.isMesh) m.castShadow = on; });
|
||||
if (o.shadowRoot) o.shadowRoot.traverse(obj => { if (obj.isMesh) obj.castShadow = on; });
|
||||
}
|
||||
|
||||
// "Lucht"-knop in een 3D-werkbalk. onChange(on) bij elke klik.
|
||||
// Knop in een 3D-werkbalk die door geen -> dag -> nacht wisselt.
|
||||
const LABELS = { none: ['cloud', 'Lucht'], day: ['sun', 'Dag'], night: ['moon-stars', 'Nacht'] };
|
||||
function createToggle(toolbar, onChange) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn-3d-toggle' + (isOn() ? ' active' : '');
|
||||
btn.title = 'Lucht en gras als omgeving';
|
||||
btn.innerHTML = '<i class="bi bi-cloud-sun"></i>Lucht';
|
||||
btn.title = 'Omgeving: geen / dag / nacht';
|
||||
let m = getMode();
|
||||
const sync = () => {
|
||||
btn.className = 'btn-3d-toggle' + (m !== 'none' ? ' active' : '');
|
||||
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}`;
|
||||
};
|
||||
btn.addEventListener('click', () => {
|
||||
const on = !btn.classList.contains('active');
|
||||
btn.classList.toggle('active', on);
|
||||
remember(on);
|
||||
onChange(on);
|
||||
m = MODES[(MODES.indexOf(m) + 1) % MODES.length];
|
||||
remember(m);
|
||||
sync();
|
||||
onChange(m);
|
||||
});
|
||||
sync();
|
||||
toolbar.appendChild(btn);
|
||||
return btn;
|
||||
}
|
||||
|
||||
return { CAMERA_HEIGHT_M, createEnvironment, apply, isOn, createToggle };
|
||||
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, createEnvironment, apply, getMode, lightFactor, createToggle };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user