feat(world): Poly-Haven-Materialien, Wasser, Himmel und mitlaufende Schatten
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No files matched your search
|
After Width: | Height: | Size: 715 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 811 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 699 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 691 KiB |
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 857 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 910 KiB |
@@ -1,9 +1,11 @@
|
|||||||
import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, MeshStandardMaterial,
|
import { ACESFilmicToneMapping, AmbientLight, BoxGeometry, DirectionalLight, Fog, Mesh,
|
||||||
PerspectiveCamera, Scene, Timer, Vector3, WebGLRenderer } from "three";
|
PerspectiveCamera, PlaneGeometry, Scene, Timer, Vector3, WebGLRenderer } from "three";
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
import { Sky } from "three/examples/jsm/objects/Sky.js";
|
||||||
import { playerController } from "three-player-controller";
|
import { playerController } from "three-player-controller";
|
||||||
import { ChunkStreamer } from "./world/streaming.js";
|
import { ChunkStreamer } from "./world/streaming.js";
|
||||||
|
import { createMaterials } from "./world/materials.js";
|
||||||
|
|
||||||
const container = document.querySelector("#container");
|
const container = document.querySelector("#container");
|
||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
@@ -23,18 +25,25 @@ sun.position.set(60, 120, 40);
|
|||||||
sun.castShadow = true;
|
sun.castShadow = true;
|
||||||
scene.add(sun);
|
scene.add(sun);
|
||||||
|
|
||||||
// Ein Material je Schicht aus buildChunkGeometry() (Task 7). buildings trägt die dort erzeugten
|
// Ein Material je Schicht aus buildChunkGeometry() (Task 7), jetzt mit echten Poly-Haven-
|
||||||
// Vertexfarben (Pastellton pro Gebäude) und bleibt deshalb weiß grundiert; die übrigen Schichten
|
// Texturen statt Flächenfarben (Task 9, src/world/materials.js). buildings trägt zusätzlich
|
||||||
// sind reine Auflagen/Landmassen ohne Vertexfarben und bekommen je einen flachen Grundton.
|
// die dort erzeugten Vertexfarben (Pastellton pro Gebäude) über der Stuckputz-Textur.
|
||||||
const materials = {
|
const materials = await createMaterials();
|
||||||
ground: new MeshStandardMaterial({ color: 0x6d7a52, roughness: 1 }),
|
|
||||||
roads: new MeshStandardMaterial({ color: 0x3a3a3d, roughness: 0.9 }),
|
// Himmel und Nebel über der Szene; sunPosition zeigt in dieselbe Richtung wie das Sonnenlicht.
|
||||||
bridges: new MeshStandardMaterial({ color: 0x8a8a8a, roughness: 0.8 }),
|
const sky = new Sky();
|
||||||
buildings: new MeshStandardMaterial({ vertexColors: true, roughness: 0.85 }),
|
sky.scale.setScalar(45000);
|
||||||
sand: new MeshStandardMaterial({ color: 0xd9c48c, roughness: 1 }),
|
scene.add(sky);
|
||||||
park: new MeshStandardMaterial({ color: 0x4f7a3d, roughness: 1 }),
|
sky.material.uniforms.turbidity.value = 8;
|
||||||
urban: new MeshStandardMaterial({ color: 0xa8a8a0, roughness: 0.9 }),
|
sky.material.uniforms.rayleigh.value = 1.6;
|
||||||
};
|
sky.material.uniforms.sunPosition.value.copy(sun.position.clone().normalize());
|
||||||
|
scene.fog = new Fog(0xbcd7ea, 500, 2200);
|
||||||
|
|
||||||
|
// Meer rund um den Ausschnitt: eine einzige große Ebene unter/um die Chunks, keine je Chunk.
|
||||||
|
const water = new Mesh(new PlaneGeometry(4000, 4000), materials.water);
|
||||||
|
water.rotation.x = -Math.PI / 2;
|
||||||
|
water.position.set(500, -0.6, -1000);
|
||||||
|
scene.add(water);
|
||||||
|
|
||||||
// Vice Beach; Task 8, Schritt 5. Auch initPos, damit der Streamer von Anfang an den richtigen
|
// Vice Beach; Task 8, Schritt 5. Auch initPos, damit der Streamer von Anfang an den richtigen
|
||||||
// Chunk anfragt statt erst nach dem späteren reset() umzuschwenken.
|
// Chunk anfragt statt erst nach dem späteren reset() umzuschwenken.
|
||||||
@@ -53,6 +62,24 @@ await player.init({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Meeresboden: man watet hinein, statt endlos zu fallen. Einmalig, nicht je Chunk.
|
||||||
|
const seafloor = new Mesh(new BoxGeometry(4000, 1, 4000), materials.seafloor);
|
||||||
|
seafloor.position.set(500, -3.0, -1000); // Oberkante bei y = -2,5
|
||||||
|
scene.add(seafloor);
|
||||||
|
seafloor.updateMatrixWorld(true);
|
||||||
|
player.addCollider({ motion: "static", shape: { kind: "mesh", source: seafloor } });
|
||||||
|
|
||||||
|
// Schattenkamera folgt dem Spieler, statt eine feste Box zu benutzen.
|
||||||
|
function updateShadow(target) {
|
||||||
|
sun.position.set(target.x + 120, 240, target.z + 80);
|
||||||
|
sun.target.position.copy(target);
|
||||||
|
sun.target.updateMatrixWorld();
|
||||||
|
sun.shadow.camera.left = -150; sun.shadow.camera.right = 150;
|
||||||
|
sun.shadow.camera.top = 150; sun.shadow.camera.bottom = -150;
|
||||||
|
sun.shadow.camera.far = 600;
|
||||||
|
sun.shadow.camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
// Chunk-Collider treffen erst nach und nach ein (Fetch + asynchroner BVH-Bau, siehe
|
// Chunk-Collider treffen erst nach und nach ein (Fetch + asynchroner BVH-Bau, siehe
|
||||||
// streaming.js). setSkipCapsuleCollision(true) schaltet nur das horizontale Herausdrücken
|
// streaming.js). setSkipCapsuleCollision(true) schaltet nur das horizontale Herausdrücken
|
||||||
// ab; applyGravity läuft im Controller unabhängig davon weiter (CharacterMovement.ts), die
|
// ab; applyGravity läuft im Controller unabhängig davon weiter (CharacterMovement.ts), die
|
||||||
@@ -99,6 +126,8 @@ renderer.setAnimationLoop((t) => {
|
|||||||
const delta = Math.min(timer.getDelta(), 1 / 40);
|
const delta = Math.min(timer.getDelta(), 1 / 40);
|
||||||
player.update(delta);
|
player.update(delta);
|
||||||
tick(delta);
|
tick(delta);
|
||||||
|
updateShadow(player.playerCapsule.position);
|
||||||
|
materials.water.normalMap.offset.x += delta * 0.01;
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
});
|
});
|
||||||
addEventListener("resize", () => {
|
addEventListener("resize", () => {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader } from "three";
|
||||||
|
|
||||||
|
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"];
|
||||||
|
export const TEXTURES = {
|
||||||
|
ground: "grass_path_2", // unbebautes Land
|
||||||
|
sand: "sand_01", // Strand
|
||||||
|
park: "leafy_grass", // Grünflächen
|
||||||
|
urban: "concrete_pavement_03", // versiegelte Stadtfläche
|
||||||
|
roads: "asphalt_02",
|
||||||
|
bridges: "concrete_pavement_03",
|
||||||
|
buildings: "white_stucco",
|
||||||
|
};
|
||||||
|
const FALLBACK_COLORS = {
|
||||||
|
ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8,
|
||||||
|
roads: 0x505050, bridges: 0xb0b0b0, buildings: 0xe8e4dc,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kachelbare Wellen-Normalmap, weil Poly Haven keine Wassertextur hat. */
|
||||||
|
export function waterNormalData(size = 128) {
|
||||||
|
const data = new Uint8Array(size * size * 4);
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
const u = (x / size) * Math.PI * 2;
|
||||||
|
const v = (y / size) * Math.PI * 2;
|
||||||
|
const dx = Math.sin(u * 3) * 0.5 + Math.sin(v * 2 + 1.3) * 0.3;
|
||||||
|
const dz = Math.cos(v * 3) * 0.5 + Math.cos(u * 2 + 0.7) * 0.3;
|
||||||
|
data[i] = 128 + dx * 40;
|
||||||
|
data[i + 1] = 128 + dz * 40;
|
||||||
|
data[i + 2] = 245;
|
||||||
|
data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSet(loader, slug, fallback) {
|
||||||
|
let map, normalMap, arm;
|
||||||
|
try {
|
||||||
|
[map, normalMap, arm] = await Promise.all(
|
||||||
|
["Diffuse", "nor_gl", "arm"].map((m) => loader.loadAsync(`./textures/${slug}_${m}.jpg`)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Textur ${slug} fehlt, einfarbiger Ersatz:`, err);
|
||||||
|
return new MeshStandardMaterial({ color: fallback, roughness: 0.9 });
|
||||||
|
}
|
||||||
|
for (const t of [map, normalMap, arm]) {
|
||||||
|
t.wrapS = t.wrapT = RepeatWrapping;
|
||||||
|
}
|
||||||
|
map.colorSpace = SRGBColorSpace;
|
||||||
|
// arm packt Ambient Occlusion, Rauheit und Metallgrad in R, G und B.
|
||||||
|
return new MeshStandardMaterial({ map, normalMap, aoMap: arm, roughnessMap: arm, metalnessMap: arm, metalness: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMaterials() {
|
||||||
|
const loader = new TextureLoader();
|
||||||
|
const entries = await Promise.all(
|
||||||
|
MATERIAL_KEYS.map(async (key) => [key, await loadSet(loader, TEXTURES[key], FALLBACK_COLORS[key])]),
|
||||||
|
);
|
||||||
|
const materials = Object.fromEntries(entries);
|
||||||
|
materials.buildings.vertexColors = true; // pastellige Tönung je Gebäude
|
||||||
|
|
||||||
|
const normal = new DataTexture(waterNormalData(128), 128, 128, RGBAFormat);
|
||||||
|
normal.wrapS = normal.wrapT = RepeatWrapping;
|
||||||
|
normal.repeat.set(60, 60);
|
||||||
|
normal.needsUpdate = true;
|
||||||
|
materials.water = new MeshStandardMaterial({
|
||||||
|
color: 0x2c67a4, normalMap: normal, roughness: 0.15, metalness: 0.2, transparent: true, opacity: 0.92,
|
||||||
|
});
|
||||||
|
materials.seafloor = new MeshStandardMaterial({ color: 0x1d4468, roughness: 1 });
|
||||||
|
return materials;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { MATERIAL_KEYS, TEXTURES, waterNormalData } from "../src/world/materials.js";
|
||||||
|
import { buildChunkGeometry } from "../src/world/geometry.js";
|
||||||
|
|
||||||
|
describe("materials", () => {
|
||||||
|
it("deckt genau die Geometrie-Teile ab", () => {
|
||||||
|
const chunk = { chunk: [0, 0], origin: [0, 0], land: [], roads: [], centerlines: [], buildings: [], palms: [] };
|
||||||
|
expect(Object.keys(buildChunkGeometry(chunk)).sort()).toEqual([...MATERIAL_KEYS].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt für jedes Material eine Textur", () => {
|
||||||
|
for (const key of MATERIAL_KEYS) expect(TEXTURES[key]).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deckt Strand und Park mit eigenen Materialien ab", () => {
|
||||||
|
expect(MATERIAL_KEYS).toContain("sand");
|
||||||
|
expect(MATERIAL_KEYS).toContain("park");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erzeugt eine Wellen-Normalmap mit neutraler Grundfarbe", () => {
|
||||||
|
const data = waterNormalData(32);
|
||||||
|
expect(data.length).toBe(32 * 32 * 4);
|
||||||
|
// Normalen zeigen im Mittel nach oben: Blaukanal nahe 255.
|
||||||
|
const blue = [...data].filter((_, i) => i % 4 === 2);
|
||||||
|
expect(blue.reduce((a, b) => a + b, 0) / blue.length).toBeGreaterThan(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Lädt die benötigten Poly-Haven-Texturen in 1k nach public/textures/.
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" };
|
||||||
|
const SLUGS = ["asphalt_02", "concrete_pavement_03", "white_stucco", "sand_01", "leafy_grass", "grass_path_2", "palm_bark"];
|
||||||
|
const MAPS = ["Diffuse", "nor_gl", "arm"];
|
||||||
|
|
||||||
|
await mkdir("public/textures", { recursive: true });
|
||||||
|
for (const slug of SLUGS) {
|
||||||
|
const files = await (await fetch(`https://api.polyhaven.com/files/${slug}`, { headers: UA })).json();
|
||||||
|
for (const map of MAPS) {
|
||||||
|
const entry = files[map]?.["1k"]?.jpg;
|
||||||
|
if (!entry) { console.warn(`${slug}: ${map} fehlt`); continue; }
|
||||||
|
const buf = Buffer.from(await (await fetch(entry.url, { headers: UA })).arrayBuffer());
|
||||||
|
await writeFile(`public/textures/${slug}_${map}.jpg`, buf);
|
||||||
|
console.log(`${slug}_${map}.jpg ${(buf.length / 1e6).toFixed(1)} MB`);
|
||||||
|
}
|
||||||
|
}
|
||||||