feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import { RENDER_QUALITY as QUALITY } from '../constants/renderQuality.ts';
import { frameSummary } from './frameSummary.ts';
export function renderPixelRatio(nativeDpr: number, cap: number, width: number, height: number, mobile: boolean) {
const pixelCap = mobile ? Math.sqrt(QUALITY.mobilePixelBudget / Math.max(1, width * height)) : Infinity;
return Math.min(nativeDpr, cap, pixelCap);
}
/** Observes consecutive rendered frames only. Never controls simulation time or drops frames. */
export function createAdaptiveResolution(maximum: number) {
const levels = QUALITY.levels.filter(value => value < maximum);
levels.push(maximum);
let index = levels.length - 1;
let previous: number | undefined, since = 0, settleUntil: number = QUALITY.settleMs, healthy = 0;
let samples: number[] = [];
const clear = () => { previous = undefined; samples = []; since = 0; healthy = 0; };
return {
cap: () => levels[index],
reset(now: number) { clear(); settleUntil = now + QUALITY.settleMs; },
restart(now: number) { index = levels.length - 1; clear(); settleUntil = now + QUALITY.settleMs; },
observe(now: number, rendered: boolean) {
if (!rendered) { clear(); return false; }
if (now < settleUntil) { previous = now; return false; }
if (previous === undefined) { previous = now; since = now; return false; }
const interval = now - previous;
previous = now;
if (!since) since = now;
samples.push(interval);
if (now - since < QUALITY.windowMs || samples.length < QUALITY.minimumSamples) return false;
const stats = frameSummary(samples);
samples = []; since = now;
let next = index;
if (stats.p95Ms! > QUALITY.lowerP95Ms) { healthy = 0; next = Math.max(0, index - 1); }
else if (stats.p95Ms! < QUALITY.raiseP95Ms && stats.meanMs! < QUALITY.raiseMeanMs) {
if (++healthy >= QUALITY.healthyWindows) { next = Math.min(levels.length - 1, index + 1); healthy = 0; }
} else healthy = 0;
if (next === index) return false;
index = next; clear(); settleUntil = now + QUALITY.settleMs;
return true;
},
};
}
+69
View File
@@ -0,0 +1,69 @@
import { Box3, BufferAttribute, BufferGeometry, Group, Sphere, Vector3, Vector4 } from 'three';
import { BUILDINGS } from '../constants/buildings.ts';
import { createBuildingMesh, finishBuildingScene } from './buildingScene.ts';
import type { BuildingCounts, BuildingHeightSource } from './buildingScene.ts';
import { restoreBuildingLightSpill } from './buildingLightSpill.ts';
import type { TerrainMetadata } from './terrainGeometry.ts';
export interface BuildingAssetManifest {
version: number;
sourceHash: string;
bufferHash: string;
byteLength: number;
terrain: Pick<TerrainMetadata, 'origin' | 'observerSurfaceElevation'>;
counts: BuildingCounts;
target: [number, number, number];
meshes: {
source: BuildingHeightSource;
vertices: number;
position: number;
normal: number;
lightHeight: number;
index: number;
indexCount: number;
indexType: 'uint16' | 'uint32';
box: [number, number, number, number, number, number];
sphere: [number, number, number, number];
}[];
spill: { offset: number; size: number; bounds: [number, number, number, number] };
}
export interface BuildingAssets { manifest: BuildingAssetManifest; buffer: ArrayBuffer }
export async function loadBuildingAssets(): Promise<BuildingAssets> {
const response = await fetch(BUILDINGS.assetManifestUrl);
if (!response.ok) throw new Error('건물 메타데이터를 불러오지 못했습니다.');
const manifest = await response.json() as BuildingAssetManifest;
if (manifest.version !== BUILDINGS.assetVersion) throw new Error('건물 파일 버전이 다릅니다. 건물을 다시 생성해 주세요.');
const binary = await fetch(`${BUILDINGS.assetBufferUrl}?v=${manifest.bufferHash}`);
if (!binary.ok) throw new Error('건물 버퍼를 불러오지 못했습니다.');
const buffer = await binary.arrayBuffer();
if (buffer.byteLength !== manifest.byteLength) throw new Error('건물 버퍼 크기가 올바르지 않습니다.');
return { manifest, buffer };
}
export function createBakedBuildings({ manifest, buffer }: BuildingAssets, terrain: TerrainMetadata) {
if (manifest.version !== BUILDINGS.assetVersion || buffer.byteLength !== manifest.byteLength) {
throw new Error('건물 파일 버전 또는 버퍼 크기가 올바르지 않습니다.');
}
if (manifest.terrain.origin.latitude !== terrain.origin.latitude || manifest.terrain.origin.longitude !== terrain.origin.longitude ||
manifest.terrain.observerSurfaceElevation !== terrain.observerSurfaceElevation) {
throw new Error('건물과 지형 기준점이 다릅니다. 건물을 다시 생성해 주세요.');
}
const group = new Group();
for (const mesh of manifest.meshes) {
const geometry = new BufferGeometry();
// Views of the downloaded buffer: no extrusion, deduplication or array copies.
geometry.setAttribute('position', new BufferAttribute(new Float32Array(buffer, mesh.position, mesh.vertices * 3), 3));
geometry.setAttribute('normal', new BufferAttribute(new Float32Array(buffer, mesh.normal, mesh.vertices * 3), 3));
geometry.setAttribute('buildingLightHeight', new BufferAttribute(new Float32Array(buffer, mesh.lightHeight, mesh.vertices), 1));
const indices = mesh.indexType === 'uint32' ? new Uint32Array(buffer, mesh.index, mesh.indexCount) : new Uint16Array(buffer, mesh.index, mesh.indexCount);
geometry.setIndex(new BufferAttribute(indices, 1));
geometry.boundingBox = new Box3(new Vector3().fromArray(mesh.box), new Vector3().fromArray(mesh.box, 3));
geometry.boundingSphere = new Sphere(new Vector3().fromArray(mesh.sphere), mesh.sphere[3]);
group.add(createBuildingMesh(mesh.source, geometry));
}
const spill = restoreBuildingLightSpill(new Uint8Array(buffer, manifest.spill.offset, manifest.spill.size ** 2),
manifest.spill.size, new Vector4().fromArray(manifest.spill.bounds));
return finishBuildingScene(group, manifest.counts, new Vector3().fromArray(manifest.target), spill);
}
+83
View File
@@ -0,0 +1,83 @@
import { DataTexture, LinearFilter, MeshStandardMaterial, RedFormat, Vector4 } from 'three';
import { BUILDINGS } from '../constants/buildings.ts';
export interface BuildingLightSource { x: number; z: number; radius: number }
/** Static, approximate ground illumination. Does not simulate shadowed light transport. */
export function createBuildingLightSpill(sources: BuildingLightSource[]) {
const size = BUILDINGS.lighting.spillMapSize;
let minX = 0, minZ = 0, maxX = 1, maxZ = 1;
if (sources.length) {
minX = minZ = Infinity;
maxX = maxZ = -Infinity;
for (const source of sources) {
minX = Math.min(minX, source.x - source.radius);
minZ = Math.min(minZ, source.z - source.radius);
maxX = Math.max(maxX, source.x + source.radius);
maxZ = Math.max(maxZ, source.z + source.radius);
}
}
const width = maxX - minX, depth = maxZ - minZ;
const accumulated = new Float32Array(size * size);
for (const source of sources) {
const left = Math.max(0, Math.floor((source.x - source.radius - minX) / width * size));
const right = Math.min(size - 1, Math.ceil((source.x + source.radius - minX) / width * size));
const top = Math.max(0, Math.floor((source.z - source.radius - minZ) / depth * size));
const bottom = Math.min(size - 1, Math.ceil((source.z + source.radius - minZ) / depth * size));
for (let row = top; row <= bottom; row++) {
for (let col = left; col <= right; col++) {
const x = minX + (col + 0.5) / size * width;
const z = minZ + (row + 0.5) / size * depth;
const distance = Math.hypot(x - source.x, z - source.z) / source.radius;
const falloff = Math.max(0, 1 - distance * distance);
accumulated[row * size + col] += falloff * falloff;
}
}
}
const pixels = new Uint8Array(accumulated.length);
for (let i = 0; i < accumulated.length; i++) {
pixels[i] = Math.round(255 * (1 - Math.exp(-accumulated[i])));
}
return restoreBuildingLightSpill(pixels, size, new Vector4(minX, minZ, width, depth));
}
export function restoreBuildingLightSpill(pixels: Uint8Array, size: number, bounds: Vector4) {
const texture = new DataTexture(pixels, size, size, RedFormat);
texture.minFilter = texture.magFilter = LinearFilter;
texture.needsUpdate = true;
const intensity = { value: 0 };
return { texture, bounds, intensity, applyTo(material: MeshStandardMaterial, facade = false) {
const previous = material.onBeforeCompile;
const previousKey = material.customProgramCacheKey();
material.onBeforeCompile = (shader, renderer) => {
previous.call(material, shader, renderer);
shader.uniforms.buildingSpillMap = { value: texture };
shader.uniforms.buildingSpillBounds = { value: bounds };
shader.uniforms.buildingSpillIntensity = intensity;
shader.vertexShader = 'varying vec2 vBuildingSpillPosition;\n' + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
'#include <begin_vertex>\nvBuildingSpillPosition = (modelMatrix * vec4(position, 1.0)).xz;');
if (facade) {
shader.vertexShader = 'attribute float buildingLightHeight;\nvarying vec2 vBuildingFacade;\n' + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
'#include <begin_vertex>\nvBuildingFacade = vec2(buildingLightHeight, 1.0 - step(0.5, abs(normal.y)));');
shader.fragmentShader = 'varying vec2 vBuildingFacade;\n' + shader.fragmentShader;
}
shader.fragmentShader = `uniform sampler2D buildingSpillMap;
uniform vec4 buildingSpillBounds;
uniform float buildingSpillIntensity;
varying vec2 vBuildingSpillPosition;\n` + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace('#include <opaque_fragment>', `
vec2 spillUv = (vBuildingSpillPosition - buildingSpillBounds.xy) / buildingSpillBounds.zw;
float inSpillArea = step(0.0, spillUv.x) * step(spillUv.x, 1.0)
* step(0.0, spillUv.y) * step(spillUv.y, 1.0);
float spill = texture2D(buildingSpillMap, spillUv).r * inSpillArea * buildingSpillIntensity;
${facade ? `spill *= vBuildingFacade.y * (1.0 - smoothstep(0.0, ${BUILDINGS.lighting.facadeFadeHeight.toFixed(1)}, vBuildingFacade.x));` : ''}
outgoingLight += diffuseColor.rgb * vec3(1.0, 0.86, 0.65) * spill;
#include <opaque_fragment>
`);
};
material.customProgramCacheKey = () => previousKey + '-building-spill-v3-' + facade;
material.needsUpdate = true;
}, dispose() { texture.dispose(); } };
}
+27
View File
@@ -0,0 +1,27 @@
import { BufferGeometry, Group, MathUtils, Mesh, MeshStandardMaterial, Vector3 } from 'three';
import { BUILDINGS } from '../constants/buildings.ts';
import type { restoreBuildingLightSpill } from './buildingLightSpill.ts';
export type BuildingHeightSource = keyof typeof BUILDINGS.colors;
export type BuildingCounts = Record<BuildingHeightSource, number>;
export function createBuildingMesh(source: BuildingHeightSource, geometry: BufferGeometry) {
const material = new MeshStandardMaterial({ color: BUILDINGS.colors[source], roughness: 0.9 });
const mesh = new Mesh(geometry, material);
mesh.name = `building-height-${source}`;
return mesh;
}
export function finishBuildingScene(group: Group, counts: BuildingCounts, target: Vector3,
spill: ReturnType<typeof restoreBuildingLightSpill>) {
group.name = 'Gwacheon VWorld buildings';
group.children.forEach(child => spill.applyTo((child as Mesh).material as MeshStandardMaterial, true));
return { group, counts, target, spill, update(altitude: number) {
const intensity = 1 - MathUtils.smoothstep(altitude,
BUILDINGS.lighting.fadeStartAltitude, BUILDINGS.lighting.fadeEndAltitude);
spill.intensity.value = intensity * BUILDINGS.lighting.spillIntensity;
}, dispose() {
spill.dispose();
group.children.forEach(child => { const m = child as Mesh; m.geometry.dispose(); (m.material as MeshStandardMaterial).dispose(); });
} };
}
+128
View File
@@ -0,0 +1,128 @@
import { BufferAttribute, BufferGeometry, ExtrudeGeometry, Group, MathUtils, Path, Shape, Vector2 } from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { BUILDINGS } from '../constants/buildings.ts';
import { TERRAIN } from '../constants/terrain.ts';
import { structurePosition } from './structures.ts';
import { terrainHeightAt } from './terrainGeometry.ts';
import type { loadTerrain } from './terrain.ts';
import { createBuildingLightSpill } from './buildingLightSpill.ts';
import type { BuildingLightSource } from './buildingLightSpill.ts';
import { createBuildingMesh, finishBuildingScene } from './buildingScene.ts';
export interface BuildingFeature {
id: string;
properties: Record<string, string>;
geometry: { type: 'Polygon'; coordinates: [number, number][][] };
}
export interface BuildingData { type: 'FeatureCollection'; features: BuildingFeature[] }
/** Exact attribute matching avoids tolerance-based changes to facade normals. */
function indexBuildingGeometry(geometry: BufferGeometry) {
const attributes = Object.entries(geometry.attributes);
const unique = new Map<string, number>();
const sourceVertices: number[] = [], indices: number[] = [];
for (let vertex = 0; vertex < geometry.getAttribute('position').count; vertex++) {
const values: number[] = [];
for (const [, attribute] of attributes) {
for (let component = 0; component < attribute.itemSize; component++) {
values.push(attribute.array[vertex * attribute.itemSize + component]);
}
}
const key = values.join(',');
let index = unique.get(key);
if (index === undefined) {
index = sourceVertices.length;
unique.set(key, index);
sourceVertices.push(vertex);
}
indices.push(index);
}
const indexed = new BufferGeometry();
for (const [name, attribute] of attributes) {
const array = new Float32Array(sourceVertices.length * attribute.itemSize);
sourceVertices.forEach((vertex, index) => {
for (let component = 0; component < attribute.itemSize; component++) {
array[index * attribute.itemSize + component] = attribute.array[vertex * attribute.itemSize + component];
}
});
indexed.setAttribute(name, new BufferAttribute(array, attribute.itemSize));
}
indexed.setIndex(indices);
return indexed;
}
export function buildingHeight(tags: Record<string, string>) {
const match = tags.height?.trim().match(/^(\d+(?:\.\d+)?)\s*(m|ft)?$/i);
const height = match ? Number(match[1]) * (match[2]?.toLowerCase() === 'ft' ? 0.3048 : 1) : 0;
if (height > 0 && height <= 400) return { meters: height, source: 'tagged' as const };
const levels = Number(tags['building:levels']);
if (levels > 0 && levels <= 100) return { meters: levels * BUILDINGS.metersPerLevel, source: 'levels' as const };
return { meters: BUILDINGS.fallbackHeight[tags.building] ?? BUILDINGS.fallbackHeight.default, source: 'fallback' as const };
}
export async function loadBuildings(): Promise<BuildingData> {
const response = await fetch(BUILDINGS.url);
if (!response.ok) throw new Error('과천 건물 데이터를 불러오지 못했습니다.');
const data = await response.json() as BuildingData;
if (data.type !== 'FeatureCollection' || !Array.isArray(data.features) || data.features.some(f => f.geometry.type !== 'Polygon' || f.geometry.coordinates.some(r => r.length < 4 || r.some(c => c.length !== 2 || !c.every(Number.isFinite))))) throw new Error('건물 외곽선 데이터 형식이 올바르지 않습니다.');
return data;
}
export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<typeof loadTerrain>>) {
const group = new Group();
const geometries: Record<'tagged' | 'levels' | 'fallback', ExtrudeGeometry[]> = { tagged: [], levels: [], fallback: [] };
const counts = { tagged: 0, levels: 0, fallback: 0 };
const lightSources: BuildingLightSource[] = [];
const surface = (x: number, z: number) => terrainHeightAt(terrain.metadata, terrain.heights, x, z) - terrain.metadata.observerSurfaceElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
for (const feature of data.features) {
const rings = feature.geometry.coordinates.map(ring => ring.slice(0, -1).map(([lon, lat]) => structurePosition(lat, lon, terrain.metadata.origin)));
const outer = rings[0];
const x = outer.reduce((sum, v) => sum + v.x, 0) / outer.length;
const z = outer.reduce((sum, v) => sum + v.z, 0) / outer.length;
// DSM can contain roofs. Use a low surrounding-surface proxy, not roof elevation + height.
const radius = Math.max(BUILDINGS.baseSampleRadius, ...outer.map(v => Math.hypot(v.x - x, v.z - z) + 15));
const samples = Array.from({ length: 8 }, (_, i) => surface(x + radius * Math.cos(i * Math.PI / 4), z + radius * Math.sin(i * Math.PI / 4)));
const base = Math.min(surface(x, z), ...samples);
const { meters, source } = buildingHeight(feature.properties);
const shape = new Shape(outer.map(v => new Vector2(v.x - x, -(v.z - z))));
for (const hole of rings.slice(1)) shape.holes.push(new Path(hole.map(v => new Vector2(v.x - x, -(v.z - z)))));
const geometry = new ExtrudeGeometry(shape, { depth: meters, bevelEnabled: false, steps: 1 });
const positions = geometry.getAttribute('position');
const lightHeights = new Float32Array(positions.count);
const unlit = ['greenhouse', 'roof', 'garage', 'garages', 'shed'].includes(feature.properties.building);
if (!unlit) {
// Approximate facade/entrance lighting along the footprint, not a city-wide circular wash.
for (let edge = 0; edge < outer.length; edge++) {
const a = outer[edge], b = outer[(edge + 1) % outer.length];
const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / BUILDINGS.lighting.spillSpacing));
for (let step = 0; step < steps; step++) {
const t = (step + 0.5) / steps;
lightSources.push({ x: MathUtils.lerp(a.x, b.x, t), z: MathUtils.lerp(a.z, b.z, t), radius: BUILDINGS.lighting.spillDistance });
}
}
}
for (let i = 0; i < positions.count; i++) {
// Extrusion is still along Z. Exterior lighting only needs local height.
lightHeights[i] = unlit ? BUILDINGS.lighting.facadeFadeHeight : positions.getZ(i);
}
geometry.setAttribute('buildingLightHeight', new BufferAttribute(lightHeights, 1));
geometry.rotateX(-Math.PI / 2);
geometry.translate(x, base, z);
geometries[source].push(geometry);
counts[source]++;
}
for (const source of ['tagged', 'levels', 'fallback'] as const) {
if (!geometries[source].length) continue;
const merged = mergeGeometries(geometries[source], false)!;
geometries[source].forEach(g => g.dispose());
// Lighting uses world XZ and local height, not UVs. Retain normals and
// lighting heights when sharing vertices so facade/roof seams stay sharp.
merged.deleteAttribute('uv');
const indexed = indexBuildingGeometry(merged);
merged.dispose();
group.add(createBuildingMesh(source, indexed));
}
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
target.y = surface(target.x, target.z) + 20;
const spill = createBuildingLightSpill(lightSources);
return finishBuildingScene(group, counts, target, spill);
}
+50
View File
@@ -0,0 +1,50 @@
import { WEATHER } from '../constants/weather.ts';
// One directional field for the sky and sun: the disc cannot shine through a
// cloud drawn at the same angle. Static patterns keep CUSTOM time deterministic.
export const cloudShader = /* glsl */ `
float weatherHash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float weatherNoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(weatherHash(i), weatherHash(i + vec2(1, 0)), f.x),
mix(weatherHash(i + vec2(0, 1)), weatherHash(i + vec2(1, 1)), f.x), f.y);
}
float weatherCloudField(vec3 direction) {
// Bounded projection keeps texture visible at and just below the horizon.
vec2 p = direction.xz / (max(direction.y, -0.06) + 0.35) * ${WEATHER.cloudScale.toFixed(2)};
float value = 0.0, weight = 0.5;
for (int i = 0; i < 5; i++) {
value += weight * weatherNoise(p);
p = mat2(1.6, -1.2, 1.2, 1.6) * p + vec2(7.3, 2.8);
weight *= 0.5;
}
// Fade detail only below the horizon; keep visible low clouds intact.
return mix(0.5, value, smoothstep(-0.06, 0.0, direction.y));
}
float weatherCloudOpacity(vec3 direction, float coverage, float field) {
float patches = smoothstep(1.0 - coverage - 0.12, 1.0 - coverage + 0.12, field);
patches *= smoothstep(0.0, 0.15, coverage);
float overcast = smoothstep(${WEATHER.overcastStart.toFixed(2)}, 1.0, coverage);
// Full overcast is opaque, including the sunrise horizon; fade below it only.
return mix(patches * ${WEATHER.cloudOpacity.toFixed(2)}, 1.0, overcast)
* smoothstep(-0.18, -0.06, direction.y);
}
`;
export const skyCloudFragment = /* glsl */ `
if (cloudCoverage > 0.0) {
float field = weatherCloudField(direction);
float opacity = weatherCloudOpacity(direction, cloudCoverage, field);
float daylight = smoothstep(-0.12, 0.22, vSunDirection.y);
float textureShade = smoothstep(0.15, 0.85, field);
vec3 cloudColor = mix(vec3(0.24, 0.27, 0.31), vec3(0.68, 0.71, 0.75), textureShade);
cloudColor *= mix(0.003, 1.8, daylight);
// Diffuse warm dawn tint, rather than the clear sky's unoccluded solar glow.
float dawn = (1.0 - smoothstep(0.0, 0.25, vSunDirection.y)) * daylight;
cloudColor += vec3(0.09, 0.045, 0.018) * dawn;
texColor = mix(texColor, cloudColor, opacity);
}
`;
+158
View File
@@ -0,0 +1,158 @@
import { MathUtils, Mesh, PlaneGeometry, ShaderMaterial, TextureLoader, Vector3, LinearFilter } from 'three';
import type { MeshStandardMaterial, Texture } from 'three';
import { COAST } from '../constants/coast.ts';
import { TERRAIN } from '../constants/terrain.ts';
import { harbourShader, isGisamun } from './harbour.ts';
import { GISAMUN_HARBOUR } from '../constants/harbour.ts';
type Ring = [number, number][];
export interface CoastData {
origin: { latitude: number; longitude: number };
radiusMeters: number;
land: Ring[];
beaches: Ring[];
shorelines: Ring[];
}
function inRing(ring: Ring, x: number, z: number) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [ax, az] = ring[i], [bx, bz] = ring[j];
if ((az > z) !== (bz > z) && x < (bx - ax) * (z - az) / (bz - az) + ax) inside = !inside;
}
return inside;
}
export function isCoastalLand(data: CoastData, x: number, z: number) {
return Math.abs(x) <= data.radiusMeters && Math.abs(z) <= data.radiusMeters && data.land.some(ring => inRing(ring, x, z));
}
export function seaHeight(x: number, z: number, originElevation: number) {
return -originElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
}
export function createOceanGeometry(originElevation: number) {
const geometry = new PlaneGeometry(COAST.oceanRadiusMeters * 2, COAST.oceanRadiusMeters * 2, COAST.oceanSegments, COAST.oceanSegments);
geometry.rotateX(-Math.PI / 2);
const positions = geometry.getAttribute('position');
for (let i = 0; i < positions.count; i++) positions.setY(i, seaHeight(positions.getX(i), positions.getZ(i), originElevation));
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
return geometry;
}
export async function loadCoast(directory = '/terrain/hajodae', origin: CoastData['origin'] = COAST,
lookTarget: CoastData['origin'] = COAST.cape): Promise<{ data: CoastData; texture: Texture; lookTarget: CoastData['origin'] }> {
const response = await fetch(`${directory}/coast.json`);
if (!response.ok) throw new Error('해안선을 불러오지 못했습니다.');
const data = await response.json() as CoastData;
if (data.origin.latitude !== origin.latitude || data.origin.longitude !== origin.longitude || data.radiusMeters !== COAST.radiusMeters) {
throw new Error('해안선과 관측점 설정이 다릅니다. 해당 해안 데이터를 다시 생성해 주세요.');
}
const texture = await new TextureLoader().loadAsync(`${directory}/surface.png`);
texture.minFilter = texture.magFilter = LinearFilter;
texture.generateMipmaps = false;
return { data, texture, lookTarget };
}
export type LoadedCoast = Awaited<ReturnType<typeof loadCoast>>;
/** Shared UV convention: PNG top row is north, local +Z points south. */
const maskUv = `vec2((vCoastPosition.x + coastRadius + 1.0), (coastRadius - vCoastPosition.z + 1.0)) / (2.0 * coastRadius + 2.0)`;
export function applyCoastalTerrain(material: MeshStandardMaterial, coast: LoadedCoast) {
// Convert shader-edge coverage into MSAA samples while retaining opaque depth.
material.alphaToCoverage = true;
material.onBeforeCompile = shader => {
shader.uniforms.coastSurface = { value: coast.texture };
shader.uniforms.coastRadius = { value: coast.data.radiusMeters };
shader.vertexShader = 'varying vec3 vCoastPosition;\n' + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvCoastPosition = position;');
shader.fragmentShader = 'uniform sampler2D coastSurface; uniform float coastRadius; varying vec3 vCoastPosition;\n' + harbourShader(isGisamun(coast.data.origin)) + '\n' + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace('#include <color_fragment>', `#include <color_fragment>
vec3 surface = texture2D(coastSurface, ${maskUv}).rgb;
// The mapped coastline footprint is rendered as a solid quay instead
// of the underlying DSM surface.
if (harbourLand(vCoastPosition.xz) > 0.0) discard;
float shoreDistance = (surface.b - .5) * ${COAST.distanceRangeMeters * 2}.0;
float shorePixel = max(fwidth(shoreDistance), .001);
diffuseColor.a *= smoothstep(-.5 * shorePixel, .5 * shorePixel, shoreDistance);
if (diffuseColor.a <= 0.0) discard;
float grainFilter = exp(-dot(fwidth(vCoastPosition.xz), vec2(1.5)));
float grain = sin(vCoastPosition.x * 1.9) * sin(vCoastPosition.z * 2.3) * 0.025 * grainFilter;
float wet = 1.0 - smoothstep(0.0, 5.0, shoreDistance);
vec3 sand = mix(vec3(.59,.46,.29),vec3(.30,.26,.19),wet*.7);
diffuseColor.rgb = mix(diffuseColor.rgb, sand + grain, surface.g);
`);
};
material.customProgramCacheKey = () => `coast-harbour-v4-${isGisamun(coast.data.origin)}`;
}
export function createOcean(coast: LoadedCoast, originElevation: number) {
const uniforms = {
coastSurface: { value: coast.texture }, coastRadius: { value: coast.data.radiusMeters },
elapsed: { value: 0 }, sunDirection: { value: new Vector3(1, 0.2, 0) }, daylight: { value: 1 },
};
const material = new ShaderMaterial({
uniforms,
vertexShader: `varying vec3 vCoastPosition;
void main() {
vCoastPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `uniform sampler2D coastSurface; uniform float coastRadius;
uniform float elapsed; uniform vec3 sunDirection; uniform float daylight;
varying vec3 vCoastPosition;
${harbourShader(isGisamun(coast.data.origin))}
float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
float noise(vec2 p) {
vec2 i=floor(p), f=fract(p); f=f*f*f*(f*(f*6.0-15.0)+10.0);
return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);
}
void main() {
vec2 uv = ${maskUv};
bool inCrop = max(abs(vCoastPosition.x),abs(vCoastPosition.z)) <= coastRadius;
float shoreDistance = (texture2D(coastSurface, uv).b - .5) * ${COAST.distanceRangeMeters * 2}.0;
// Keep continuous water beneath the terrain's partially covered MSAA
// samples. Complementary cutouts would leave holes at the shoreline.
vec2 p = vCoastPosition.xz;
float shelter = smoothstep(0.0, ${GISAMUN_HARBOUR.entranceBlendMeters.toFixed(1)}, harbourBasin(p));
vec3 view = normalize(cameraPosition - vCoastPosition);
float range = length(cameraPosition.xz - p);
// Fade frequencies smaller than a screen pixel to avoid distant moire.
float footprint = max(length(dFdx(p)), length(dFdy(p)));
float ripple = exp(-range / 1200.0) * (1.0-smoothstep(.25,1.0,footprint*.37));
float fineRipple = exp(-range / 1200.0) * (1.0-smoothstep(.25,1.0,footprint*1.9));
ripple *= mix(1.0, ${GISAMUN_HARBOUR.shelterWaveScale}, shelter);
fineRipple *= mix(1.0, ${GISAMUN_HARBOUR.shelterWaveScale}, shelter);
vec2 flow = vec2(elapsed*.18,elapsed*.07);
vec2 waves = vec2(noise(p*.32+flow),noise(p*.37-flow+19.7))-.5;
vec2 detail = vec2(noise(p*1.7-flow*2.0),noise(p*1.9+flow*1.4+41.3))-.5;
vec3 n = normalize(vec3(waves.x*.12*ripple+detail.x*.05*fineRipple,1.0,
waves.y*.12*ripple+detail.y*.05*fineRipple));
float fresnel = pow(1.0-max(dot(view,n),0.0),4.0);
vec3 deep = vec3(.015,.115,.15);
vec3 sky = mix(vec3(.24,.33,.40), vec3(.42,.48,.51), smoothstep(800.0,14000.0,range));
vec3 color = mix(deep,sky,fresnel*.85) * daylight;
vec3 halfVector = normalize(view + sunDirection);
float specular = pow(max(dot(n,halfVector),0.0),190.0);
float sunVisible = smoothstep(-.015,.025,sunDirection.y);
vec3 sunColor = mix(vec3(1.0,.34,.08),vec3(1.0,.94,.76),smoothstep(0.0,.4,sunDirection.y));
color += sunColor * specular * sunVisible * 2.4;
if (inCrop) {
float wash = .9+.55*sin(elapsed*.8+noise(p*.08)*2.0);
float foam = exp(-pow((shoreDistance+wash)/.7,2.0)) * (.16+.28*noise(p*1.4-flow));
foam *= exp(-footprint*.2);
// Do not draw a sandy swash line along vertical harbour walls.
foam *= smoothstep(0.0, 4.0, -harbourLand(p)) * (1.0-shelter);
color = mix(color,vec3(.64,.74,.73)*daylight,foam);
}
gl_FragColor = vec4(color,1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
const mesh = new Mesh(createOceanGeometry(originElevation), material);
mesh.name = 'Coastal sea (visual waves, no tide model)';
return { mesh,
animate(seconds: number) { uniforms.elapsed.value = seconds; },
update(direction: Vector3, altitude: number) {
uniforms.sunDirection.value.copy(direction);
uniforms.daylight.value = MathUtils.lerp(0.025, 1, MathUtils.smoothstep(altitude, -10, 10));
},
dispose() { mesh.geometry.dispose(); material.dispose(); coast.texture.dispose(); },
};
}
+14
View File
@@ -0,0 +1,14 @@
/** Nearest-rank percentile; long foreground frames must remain in the sample. */
export function frameSummary(intervals: readonly number[]) {
const sorted = [...intervals].sort((a, b) => a - b);
const total = sorted.reduce((sum, value) => sum + value, 0);
return {
samples: sorted.length,
fps: total > 0 ? sorted.length * 1000 / total : null,
meanMs: sorted.length ? total / sorted.length : null,
p95Ms: sorted.length ? sorted[Math.ceil(sorted.length * 0.95) - 1] : null,
maxMs: sorted.length ? sorted[sorted.length - 1] : null,
overBudget: sorted.filter(value => value > 1000 / 30 + 0.1).length,
over50Ms: sorted.filter(value => value > 50).length,
};
}
+60
View File
@@ -0,0 +1,60 @@
import { BoxGeometry, ExtrudeGeometry, Group, Mesh, MeshStandardMaterial, Shape, Vector2 } from 'three';
import { GISAMUN_HARBOUR as PORT } from '../constants/harbour.ts';
import { GISAMUN } from '../constants/gisamun.ts';
import { TERRAIN } from '../constants/terrain.ts';
type Ring = readonly (readonly [number, number])[];
export function isGisamun(origin: { latitude: number; longitude: number }) {
return origin.latitude === GISAMUN.latitude && origin.longitude === GISAMUN.longitude;
}
export function insideHarbour(ring: Ring, x: number, z: number) {
let inside = false;
for (let i=0,j=ring.length-1;i<ring.length;j=i++) {
const [ax,az]=ring[i], [bx,bz]=ring[j];
if ((az>z)!==(bz>z) && x<(bx-ax)*(z-az)/(bz-az)+ax) inside=!inside;
}
return inside;
}
// Same vector outlines drive geometry, terrain replacement and wave shelter.
export function harbourShader(enabled: boolean) {
if (!enabled) return 'float harbourLand(vec2 p){return -10000.;} float harbourBasin(vec2 p){return -10000.;}';
return ([['harbourLand',PORT.land],['harbourBasin',PORT.basin]] as const).map(([name,ring]) => `
float ${name}(vec2 p) {
vec2 points[${ring.length}] = vec2[${ring.length}](${ring.map(([x,z])=>`vec2(${x.toFixed(3)},${z.toFixed(3)})`).join(',')});
float distanceToEdge=10000.; bool inside=false;
for(int i=0,j=${ring.length-1};i<${ring.length};j=i++) {
vec2 a=points[i], b=points[j], e=b-a;
distanceToEdge=min(distanceToEdge,length(p-a-e*clamp(dot(p-a,e)/dot(e,e),0.,1.)));
if ((a.y>p.y)!=(b.y>p.y)) {
if(p.x<(b.x-a.x)*(p.y-a.y)/(b.y-a.y)+a.x) inside=!inside;
}
}
return inside ? distanceToEdge : -distanceToEdge;
}`).join('\n');
}
export function createHarbour(originElevation: number) {
const group=new Group(); group.name='Gisamun harbour (estimated heights)';
const top=new MeshStandardMaterial({color:PORT.topColor,roughness:.95});
const wall=new MeshStandardMaterial({color:PORT.wallColor,roughness:1});
const shape=new Shape(PORT.land.map(([x,z])=>new Vector2(x,-z)));
const geometry=new ExtrudeGeometry(shape,{depth:PORT.topElevation-PORT.bottomElevation,bevelEnabled:false,steps:1,curveSegments:1});
geometry.rotateX(-Math.PI/2);
geometry.translate(0,PORT.bottomElevation-originElevation,0);
const p=geometry.getAttribute('position');
for(let i=0;i<p.count;i++) p.setY(i,p.getY(i)-(p.getX(i)**2+p.getZ(i)**2)/(2*TERRAIN.earthRadiusMeters));
geometry.computeVertexNormals();
const apron=new Mesh(geometry,[top,wall]); apron.name='Breakwaters and quay'; group.add(apron);
for(let i=1;i<PORT.parapet.length;i++) {
const [ax,az]=PORT.parapet[i-1], [bx,bz]=PORT.parapet[i];
const x=(ax+bx)/2,z=(az+bz)/2;
const mesh=new Mesh(new BoxGeometry(PORT.parapetWidth,PORT.parapetHeight,Math.hypot(bx-ax,bz-az)),wall);
mesh.position.set(x,PORT.topElevation-originElevation+PORT.parapetHeight/2-(x*x+z*z)/(2*TERRAIN.earthRadiusMeters),z);
mesh.rotation.y=Math.atan2(bx-ax,bz-az); group.add(mesh);
}
return {group,
heightAt(x:number,z:number) {return insideHarbour(PORT.land,x,z) ? PORT.topElevation-originElevation-(x*x+z*z)/(2*TERRAIN.earthRadiusMeters) : undefined;},
dispose() {group.traverse(o=>{if(o instanceof Mesh)o.geometry.dispose();});top.dispose();wall.dispose();},
};
}
+54
View File
@@ -0,0 +1,54 @@
import { BackSide, Color, Group, MathUtils, Mesh, MeshBasicMaterial, SphereGeometry, Vector3 } from 'three';
import { SCENE } from '../constants/scene.ts';
import { TERRAIN } from '../constants/terrain.ts';
export function createHorizon(eye: Vector3, surfaceElevation: number) {
const group = new Group();
// Outside the cropped DSM, use a simplified sea-level Earth horizon,
// never an invented continuation of terrain. No visible 0° guide line.
group.position.copy(eye);
// Match the terrain's distant fog, and feather only the visible color at the rim.
// Keep depth writes even at zero alpha: the sea-level horizon must still hide the sun.
const rim = { value: 0 };
const groundMaterial = new MeshBasicMaterial({ color: '#0b111a', side: BackSide, fog: true, transparent: true, depthWrite: true });
groundMaterial.onBeforeCompile = shader => {
shader.uniforms.horizonRim = rim;
shader.uniforms.horizonBlend = { value: Math.sin(MathUtils.degToRad(SCENE.horizonBlendDegrees)) };
shader.vertexShader = 'varying vec3 vHorizonDirection;\n' + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvHorizonDirection = position;');
shader.fragmentShader = 'varying vec3 vHorizonDirection;\nuniform float horizonRim;\nuniform float horizonBlend;\n' + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace('#include <fog_fragment>', `#include <fog_fragment>
float belowRim = horizonRim - normalize(vHorizonDirection).y;
gl_FragColor.a *= smoothstep(0.0, horizonBlend, belowRim);`);
};
const groundGeometry = (height: number) => {
const dip = Math.acos(TERRAIN.earthRadiusMeters / (TERRAIN.earthRadiusMeters + surfaceElevation + height));
rim.value = -Math.sin(dip);
return new SphereGeometry(SCENE.horizonRadius, 96, 32, 0, Math.PI * 2, Math.PI / 2 + dip, Math.PI / 2 - dip);
};
const ground = new Mesh(groundGeometry(eye.y), groundMaterial);
let lastHeight = eye.y;
group.add(ground);
const night = new Color('#070c15');
const day = new Color('#26343e');
return {
group,
move(position: Vector3, elevation: number, eyeHeight: number) {
group.position.copy(position);
if (Math.abs(surfaceElevation + lastHeight - elevation - eyeHeight) > 0.5) {
surfaceElevation = elevation;
lastHeight = eyeHeight;
ground.geometry.dispose();
ground.geometry = groundGeometry(eyeHeight);
}
},
setEyeHeight(meters: number) {
group.position.y = meters;
ground.geometry.dispose();
ground.geometry = groundGeometry(meters);
},
update(altitude: number) {
groundMaterial.color.copy(night).lerp(day, MathUtils.smoothstep(altitude, -8, 15));
},
};
}
+132
View File
@@ -0,0 +1,132 @@
import { ACESFilmicToneMapping, Box3, Color, Fog, HemisphereLight, MathUtils, OrthographicCamera, PerspectiveCamera, Scene, Vector3, WebGLRenderer, Mesh } from 'three';
import { getMoonPosition } from 'suncalc';
import { loadSheep } from './sheep.ts';
import { createTerrain, loadTerrain } from './terrain.ts';
import { createSky } from './sky.ts';
import { createSun } from './sun.ts';
import { createNightSky } from './nightSky.ts';
import { calculateSun } from '../astronomy/sunCalculator.ts';
import { directionFromAngles } from '../astronomy/direction.ts';
import { summitPosition } from '../controls/summitCoordinates.ts';
import { LOCATIONS } from '../constants/locations.ts';
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
import { SCENE } from '../constants/scene.ts';
import { TERRAIN } from '../constants/terrain.ts';
import { LOBBY_CHARACTER, LOBBY_SUNRISE } from '../constants/lobby.ts';
/** Same Gwanaksan DSM, summit correction, sky and solar disc as the observation scene. */
export function createLobbySunrise(canvas: HTMLCanvasElement) {
let disposed = false;
let cleanup: (() => void) | undefined;
const ready = (async () => {
const site = LOCATIONS.gwanaksan;
const [data, sheep] = await Promise.all([loadTerrain(site.terrain, site.observer), loadSheep()]);
if (disposed) { sheep.dispose(); return; }
const renderer = new WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = ACESFilmicToneMapping;
const terrain = createTerrain(data, undefined, true);
const scene = new Scene();
const camera = new PerspectiveCamera(LOBBY_SUNRISE.fieldOfView, 1, SCENE.near, SCENE.far);
const start = summitPosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, data.metadata.origin);
const ground = terrain.heightAt(start.x, start.z);
camera.position.set(start.x, ground + site.observer.eyeHeight, start.z);
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(LOBBY_SUNRISE.heading, LOBBY_SUNRISE.pitch))));
const observer = { ...site.observer, ...SUMMIT_TERRAIN.start, elevation: ground + data.metadata.observerSurfaceElevation };
const sky = createSky();
const sun = createSun(camera.position);
const nightSky = createNightSky(camera.position);
const ambient = new HemisphereLight('#c7def0', '#3e4235', 1.5);
const fog = new Fog('#060b15', TERRAIN.fogNear, TERRAIN.fogFar);
const night = new Color('#060b15'), dawn = new Color('#383e38'), day = new Color('#a6b6bc');
scene.fog = fog;
scene.add(terrain.mesh, sky.mesh, sun.group, sun.light, sun.light.target, nightSky.group, ambient);
const direction = new Vector3();
// A foreground pass keeps the existing model in the lower-left on every aspect ratio.
const foreground = new Scene();
sheep.group.rotation.y = MathUtils.degToRad(LOBBY_CHARACTER.heading);
const bounds = new Box3();
sheep.group.updateMatrixWorld(true);
sheep.group.traverseVisible(object => {
if (!(object instanceof Mesh)) return;
object.geometry.computeBoundingBox();
bounds.union(object.geometry.boundingBox!.clone().applyMatrix4(object.matrixWorld));
});
const characterHeight = bounds.max.y - bounds.min.y;
sheep.group.position.y -= bounds.min.y;
const halfWidth = characterHeight * (LOBBY_CHARACTER.frameTop - LOBBY_CHARACTER.frameBottom) / 2;
const characterCamera = new OrthographicCamera(-halfWidth, halfWidth,
characterHeight * LOBBY_CHARACTER.frameTop, characterHeight * LOBBY_CHARACTER.frameBottom, 0.1, 20);
characterCamera.position.z = 5;
foreground.add(sheep.group);
const head = sheep.group.getObjectByName('Head')!;
const headRest = head.quaternion.clone();
let progress = matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 0;
let frame = 0;
let elapsed = 0;
let lastFrame = 0;
const render = () => {
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
const state = calculateSun(date, observer);
direction.set(...directionFromAngles(state.azimuth, state.altitude));
sky.update(direction, state.altitude);
sun.update(direction, state.altitude);
nightSky.update(date, observer, state.altitude);
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
head.quaternion.copy(headRest);
head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28)));
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
renderer.render(scene, camera);
const { clientWidth: width, clientHeight: height } = canvas;
const size = Math.min(LOBBY_CHARACTER.maxSize, width * LOBBY_CHARACTER.widthFraction, height * LOBBY_CHARACTER.heightFraction);
renderer.autoClear = false;
renderer.clearDepth();
renderer.setViewport(width * LOBBY_CHARACTER.marginFraction, height * LOBBY_CHARACTER.marginFraction, size, size);
renderer.render(foreground, characterCamera);
renderer.setViewport(0, 0, width, height);
renderer.autoClear = true;
};
const resize = () => {
const { clientWidth: width, clientHeight: height } = canvas;
camera.aspect = width / Math.max(1, height);
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio));
renderer.setSize(width, height, false);
render();
};
const tick = (now: number) => {
if (disposed) return;
if (!document.hidden && lastFrame) elapsed += now - lastFrame;
lastFrame = now;
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
if (!document.hidden) render();
if (progress < 1) frame = requestAnimationFrame(tick);
};
const visible = () => { lastFrame = 0; if (!document.hidden) render(); };
cleanup = () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', resize);
document.removeEventListener('visibilitychange', visible);
terrain.dispose();
nightSky.dispose();
sheep.dispose();
sky.mesh.geometry.dispose(); sky.mesh.material.dispose();
sun.group.traverse(object => {
if (object instanceof Mesh) {
object.geometry.dispose();
for (const material of Array.isArray(object.material) ? object.material : [object.material]) material.dispose();
}
});
renderer.dispose(); renderer.forceContextLoss();
};
window.addEventListener('resize', resize);
document.addEventListener('visibilitychange', visible);
resize();
if (progress < 1) frame = requestAnimationFrame(tick);
})();
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
}
+106
View File
@@ -0,0 +1,106 @@
import { BufferGeometry, Float32BufferAttribute, Group, MathUtils, Mesh, PlaneGeometry, Points, ShaderMaterial, Vector3 } from 'three';
import { getMoonIllumination, getMoonPosition } from 'suncalc';
import type { ObserverConfig } from '../config/observer.ts';
import { directionFromAngles } from '../astronomy/direction.ts';
import { NIGHT_SKY } from '../constants/nightSky.ts';
import { cloudShader } from './clouds.ts';
/** Decorative, deterministic stars; lunar position and phase follow the selected time. */
export function createNightSky(eye: Vector3) {
const group = new Group();
const coverage = { value: 0 };
const visibility = { value: 0 };
const moonVisibility = { value: 0 };
let seed: number = NIGHT_SKY.seed;
const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
const positions: number[] = [], sizes: number[] = [], brightness: number[] = [];
for (let i = 0; i < NIGHT_SKY.starCount; i++) {
const y = random(), azimuth = random() * Math.PI * 2;
const radius = Math.sqrt(1 - y * y);
positions.push(radius * Math.cos(azimuth) * NIGHT_SKY.distance, y * NIGHT_SKY.distance,
radius * Math.sin(azimuth) * NIGHT_SKY.distance);
sizes.push(1.2 + Math.pow(random(), 3) * 1.8);
brightness.push(0.35 + Math.pow(random(), 2) * 0.65);
}
const starGeometry = new BufferGeometry();
starGeometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
starGeometry.setAttribute('size', new Float32BufferAttribute(sizes, 1));
starGeometry.setAttribute('brightness', new Float32BufferAttribute(brightness, 1));
const starMaterial = new ShaderMaterial({
uniforms: { coverage, visibility, pixelRatio: { value: 1 } },
transparent: true, depthWrite: false, toneMapped: false,
vertexShader: `attribute float size; attribute float brightness;
uniform float pixelRatio; varying float vBrightness; varying vec3 vDirection;
void main() {
vBrightness = brightness; vDirection = normalize(position);
gl_Position = projectionMatrix * viewMatrix * vec4(cameraPosition + position, 1.0);
gl_PointSize = size * pixelRatio;
}`,
fragmentShader: `${cloudShader}
uniform float coverage; uniform float visibility;
varying float vBrightness; varying vec3 vDirection;
void main() {
float disc = 1.0 - smoothstep(0.1, 0.5, length(gl_PointCoord - 0.5));
float cloud = weatherCloudOpacity(vDirection, coverage, weatherCloudField(vDirection));
float horizon = smoothstep(0.0, 0.12, vDirection.y);
gl_FragColor = vec4(vec3(0.83, 0.9, 1.0) * ${NIGHT_SKY.starRadiance.toFixed(1)}, disc * vBrightness * visibility * horizon * (1.0 - cloud));
#include <colorspace_fragment>
}`,
});
const stars = new Points(starGeometry, starMaterial);
stars.frustumCulled = false;
stars.renderOrder = 1;
stars.onBeforeRender = renderer => { starMaterial.uniforms.pixelRatio.value = renderer.getPixelRatio(); };
const moonMaterial = new ShaderMaterial({
uniforms: { coverage, moonVisibility, lightDirection: { value: new Vector3() } },
transparent: true, depthWrite: false,
vertexShader: `varying vec2 vUv; varying vec3 vDirection;
void main() { vUv = uv;
vec4 world = modelMatrix * vec4(position, 1.0);
vDirection = normalize(world.xyz - cameraPosition);
gl_Position = projectionMatrix * viewMatrix * world;
}`,
fragmentShader: `${cloudShader}
uniform float coverage; uniform float moonVisibility; uniform vec3 lightDirection;
varying vec2 vUv; varying vec3 vDirection;
void main() {
vec2 p = (vUv - 0.5) * 2.0;
float r2 = dot(p, p); if (r2 > 1.0) discard;
vec3 normal = vec3(p, sqrt(max(0.0, 1.0 - r2)));
float light = smoothstep(-0.015, 0.035, dot(normal, lightDirection));
float shading = 0.65 + 0.35 * normal.z;
float cloud = weatherCloudOpacity(vDirection, coverage, weatherCloudField(vDirection));
gl_FragColor = vec4(vec3(0.91, 0.94, 1.0) * ${NIGHT_SKY.moonRadiance.toFixed(1)} * shading,
light * (1.0 - smoothstep(0.96, 1.0, r2)) * (1.0 - cloud) * moonVisibility);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
const moonGeometry = new PlaneGeometry(1, 1);
const moon = new Mesh(moonGeometry, moonMaterial);
moon.renderOrder = 3;
group.add(stars, moon);
return {
group,
update(date: Date, observer: ObserverConfig, sunAltitude: number, cloudCover = 0) {
coverage.value = cloudCover;
visibility.value = 1 - MathUtils.smoothstep(sunAltitude, NIGHT_SKY.fadeStartAltitude, NIGHT_SKY.fadeEndAltitude);
stars.visible = visibility.value > 0;
moonVisibility.value = 1 - MathUtils.smoothstep(sunAltitude, NIGHT_SKY.moonFadeStartAltitude, NIGHT_SKY.moonFadeEndAltitude);
moon.visible = moonVisibility.value > 0;
const position = getMoonPosition(date, observer.latitude, observer.longitude);
const illumination = getMoonIllumination(date);
moon.position.copy(eye).addScaledVector(new Vector3(...directionFromAngles(position.azimuth, position.altitude)), NIGHT_SKY.distance);
moon.lookAt(eye);
moon.scale.setScalar(2 * NIGHT_SKY.distance * NIGHT_SKY.moonRadiusKm / position.distance);
const angle = MathUtils.degToRad(illumination.angle - position.parallacticAngle);
const z = 2 * illumination.fraction - 1;
const xy = Math.sqrt(Math.max(0, 1 - z * z));
moonMaterial.uniforms.lightDirection.value.set(-Math.sin(angle) * xy, Math.cos(angle) * xy, z);
// The existing terrain and Earth horizon provide depth occlusion.
},
dispose() {
starGeometry.dispose(); starMaterial.dispose(); moonGeometry.dispose(); moonMaterial.dispose();
},
};
}
+99
View File
@@ -0,0 +1,99 @@
import { BufferAttribute, BufferGeometry, Color, DynamicDrawUsage, Group, LineBasicMaterial, LineSegments, MathUtils, Points, ShaderMaterial } from 'three';
import type { Vector3 } from 'three';
import { PRECIPITATION as P } from '../constants/precipitation.ts';
/** Bounded, reusable particles around the viewing camera, in world coordinates. */
export function createPrecipitation(heightAt: (x: number, z: number) => number) {
const group = new Group();
group.name = 'weather-precipitation';
const rainMaterial = new LineBasicMaterial({ color: '#b9cddd', transparent: true, depthWrite: false });
const snowMaterial = new ShaderMaterial({
transparent: true, depthWrite: false,
uniforms: { tint: { value: new Color() }, opacity: { value: P.snow.opacity }, pointScale: { value: 1 } },
vertexShader: `
uniform float pointScale;
void main() {
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * viewPosition;
gl_PointSize = clamp(${P.snow.size} * pointScale * projectionMatrix[1][1] / max(0.1, -viewPosition.z), 1.0, 24.0);
}`,
fragmentShader: `
uniform vec3 tint;
uniform float opacity;
void main() {
float radius = length(gl_PointCoord - 0.5) * 2.0;
float alpha = (1.0 - smoothstep(0.3, 1.0, radius)) * opacity;
if (alpha < 0.01) discard;
gl_FragColor = vec4(tint, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
const layers = ([false, true] as const).map(snow => {
const settings = snow ? P.snow : P.rain;
const vertices = snow ? 1 : 2;
const geometry = new BufferGeometry();
const attribute = new BufferAttribute(new Float32Array(settings.maxParticles * vertices * 3), 3).setUsage(DynamicDrawUsage);
geometry.setAttribute('position', attribute);
geometry.setDrawRange(0, 0);
const mesh = snow ? new Points(geometry, snowMaterial) : new LineSegments(geometry, rainMaterial);
// The buffer moves with the camera; avoid recomputing its bounds every frame.
mesh.frustumCulled = false;
mesh.visible = false;
group.add(mesh);
return { snow, settings, vertices, mesh, attribute,
positions: new Float32Array(settings.maxParticles * 3),
ground: new Float32Array(settings.maxParticles),
seeds: Float32Array.from({ length: settings.maxParticles }, () => Math.random()), count: 0 };
});
let elapsed = 0;
const span = P.radius * 2;
const wrap = (value: number, center: number) => MathUtils.euclideanModulo(value - center + P.radius, span) + center - P.radius;
return {
group,
update(delta: number, camera: Vector3, rain: number, snowfall: number, daylight: number, viewportHeight: number) {
const dt = Math.min(Math.max(delta, 0), P.maxDelta);
elapsed += dt;
rainMaterial.color.setRGB(0.65, 0.75, 0.85).multiplyScalar(0.25 + daylight * 0.75);
snowMaterial.uniforms.tint.value.setRGB(0.9, 0.95, 1).multiplyScalar(0.25 + daylight * 0.75);
snowMaterial.uniforms.pointScale.value = viewportHeight / 2;
for (const layer of layers) {
const { snow, settings, vertices, mesh, attribute, positions, ground, seeds } = layer;
const rate = snow ? snowfall : rain;
const strength = Math.sqrt(MathUtils.clamp(rate / settings.fullRate, 0, 1));
const count = Math.floor(settings.maxParticles * strength);
mesh.visible = count > 0;
mesh.geometry.setDrawRange(0, count * vertices);
if (!snow) rainMaterial.opacity = P.rain.opacity + strength * 0.3;
for (let i = 0; i < count; i++) {
const offset = i * 3, seed = seeds[i];
const fresh = i >= layer.count;
let x = fresh ? camera.x + (Math.random() - 0.5) * span : positions[offset];
let z = fresh ? camera.z + (Math.random() - 0.5) * span : positions[offset + 2];
if (snow) {
x += Math.sin(elapsed + seed * Math.PI * 2) * P.snow.drift * dt;
z += Math.cos(elapsed * 0.7 + seed * Math.PI * 2) * P.snow.drift * dt;
}
x = wrap(x, camera.x); z = wrap(z, camera.z);
if (fresh || snow || x !== positions[offset] || z !== positions[offset + 2]) ground[i] = heightAt(x, z);
const bottom = Math.max(camera.y - P.belowCamera, ground[i] + 0.05);
const top = Math.max(bottom + 0.1, camera.y + P.height - P.belowCamera);
const speed = settings.speed * (0.8 + seed * 0.4) * (snow ? 1 : 1 + strength * 0.4);
let y = fresh ? MathUtils.lerp(bottom, top, seed) : positions[offset + 1] - speed * dt;
if (y < bottom || y > top) y = top;
positions[offset] = x; positions[offset + 1] = y; positions[offset + 2] = z;
attribute.setXYZ(i * vertices, x, y, z);
if (!snow) attribute.setXYZ(i * vertices + 1, x, y + P.rain.streak * (0.6 + strength), z);
}
if (count) attribute.needsUpdate = true;
layer.count = count;
}
return layers.some(layer => layer.mesh.visible);
},
dispose() {
for (const layer of layers) layer.mesh.geometry.dispose();
rainMaterial.dispose(); snowMaterial.dispose();
group.removeFromParent();
},
};
}
+22
View File
@@ -0,0 +1,22 @@
import { Quaternion, Vector3 } from 'three';
import type { Camera } from 'three';
/** Keep input polling active, but skip GPU work when the scene has not changed. */
export function createRenderInvalidation() {
let dirty = true;
let previousCamera: Camera | undefined;
const position = new Vector3();
const rotation = new Quaternion();
return {
invalidate() { dirty = true; },
shouldRender(camera: Camera, animated = false) {
if (!dirty && !animated && previousCamera === camera
&& position.equals(camera.position) && rotation.equals(camera.quaternion)) return false;
dirty = false;
previousCamera = camera;
position.copy(camera.position);
rotation.copy(camera.quaternion);
return true;
},
};
}
+168
View File
@@ -0,0 +1,168 @@
import { BackSide, Group, MathUtils, Mesh, MeshBasicMaterial, Quaternion, Vector3, type Material, type Object3D } from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { CHARACTER } from '../constants/character.ts';
import { RAIN_GEAR } from '../constants/rainGear.ts';
import type { Emote } from '../controls/emotes.ts';
export interface Sheep {
group: Group;
clone(): Sheep;
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp?: boolean): void;
updateLighting(altitude: number, ambient: number): void;
pose(emote: Emote, elapsed: number, moving: boolean, time: number): boolean;
dispose(): void;
}
export async function loadSheep() {
const loader = new GLTFLoader();
const [gltf, umbrella, raincoat] = await Promise.all(
[CHARACTER.url, RAIN_GEAR.umbrellaUrl, RAIN_GEAR.raincoatUrl].map(url => loader.loadAsync(url)));
return createSheep(gltf.scene, { umbrella: umbrella.scene, raincoat: raincoat.scene });
}
export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Group }): Sheep {
const group = new Group();
group.name = 'PlayerSheep';
group.add(model);
group.scale.setScalar(CHARACTER.scale);
const joints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
.map(name => {
const joint = model.getObjectByName(name);
if (!joint) throw new Error(`양 모델 관절 누락: ${name}`);
return [name, joint];
})) as Record<string, Object3D>;
const rest = Object.fromEntries(Object.entries(joints).map(([name, joint]) => [name, joint.quaternion.clone()]));
const coatParts: Object3D[] = [];
let hood: Object3D | undefined;
let foldedHood: Object3D | undefined;
if (gear) {
model.add(gear.umbrella, gear.raincoat);
hood = gear.raincoat.getObjectByName('CoatHood');
foldedHood = gear.raincoat.getObjectByName('CoatFoldedHood');
if (!hood || !foldedHood) throw new Error('우비 후드 모델 누락');
joints.Head.add(hood);
hood.position.set(0, 0, 0); hood.quaternion.identity(); hood.scale.setScalar(1);
hood.visible = false;
coatParts.push(gear.raincoat);
for (const name of ['ArmLeft', 'ArmRight']) {
const sleeve = gear.raincoat.getObjectByName(`Coat${name}`);
if (!sleeve) throw new Error(`비옷 소매 누락: ${name}`);
joints[name].add(sleeve);
sleeve.position.set(0, 0, 0); sleeve.quaternion.identity(); sleeve.scale.setScalar(1);
coatParts.push(sleeve);
}
gear.umbrella.visible = false;
coatParts.forEach(part => { part.visible = false; });
}
const materials = new Set<MeshBasicMaterial>();
const converted = new Map<Material, MeshBasicMaterial>();
const outlineMaterial = new MeshBasicMaterial({ color: CHARACTER.ink, side: BackSide, toneMapped: false });
materials.add(outlineMaterial);
const meshes: Mesh[] = [];
model.traverse(object => { if (object instanceof Mesh) meshes.push(object); });
for (const mesh of meshes) {
const sourceMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const alreadyInk = sourceMaterials.every(material => material.name.startsWith('SheepV2_Ink'));
const replace = (source: Material) => {
let material = converted.get(source);
if (!material) {
material = new MeshBasicMaterial({ color: 'color' in source ? source.color as never : CHARACTER.ink, toneMapped: false });
converted.set(source, material);
materials.add(material);
}
return material;
};
mesh.material = Array.isArray(mesh.material) ? mesh.material.map(replace) : replace(mesh.material);
// Small facial marks and the badge are already drawn shapes, not silhouettes.
if (alreadyInk || mesh.userData.sheepOutline === false || mesh.parent?.userData.sheepOutline === false) continue;
const hull = mesh.geometry.clone();
const positions = hull.attributes.position;
const normals = hull.attributes.normal;
// Custom glTF attributes keep their authored Blender Z-up components.
const offsets = hull.attributes._outline_offset;
for (let i = 0; i < positions.count; i++) {
positions.setXYZ(i, positions.getX(i) + (offsets ? offsets.getX(i) : normals.getX(i) * CHARACTER.outline),
positions.getY(i) + (offsets ? offsets.getZ(i) : normals.getY(i) * CHARACTER.outline),
positions.getZ(i) + (offsets ? -offsets.getY(i) : normals.getZ(i) * CHARACTER.outline));
}
hull.computeBoundingSphere();
const outline = new Mesh(hull, outlineMaterial);
outline.name = 'Outline';
mesh.add(outline);
}
for (const source of converted.keys()) source.dispose();
const baseColors = new Map([...materials].map(material => [material, material.color.clone()]));
const originals: Object3D[] = [];
group.traverse(object => originals.push(object));
function bindRig(group: Group, ownsAssets: boolean): Sheep {
const copies: Object3D[] = [];
group.traverse(object => copies.push(object));
const copy = (object: Object3D | undefined) => object ? copies[originals.indexOf(object)] : undefined;
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
.map(name => [name, group.getObjectByName(name)!]));
const joints = rigJoints;
const rigCoatParts = coatParts.map(part => copy(part)!);
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
let umbrellaOpen = false;
const rotation = new Quaternion();
const down = new Vector3(0, -1, 0);
const aim = new Vector3();
let previousMoving = false;
let previousEmote: Emote = 'idle';
// Exported pivot rotations are identity in glTF's Y-up coordinates.
function pointArm(name: string, direction: Vector3) {
rotation.setFromUnitVectors(down, direction.normalize());
joints[name].quaternion.copy(rotation).multiply(rest[name]);
}
return {
group,
clone() { return bindRig(group.clone(true), false); },
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false) {
umbrellaOpen = umbrella;
if (rigUmbrella) rigUmbrella.visible = umbrella;
rigCoatParts.forEach(part => { part.visible = raincoat; });
if (rigHood) rigHood.visible = raincoat && hoodUp;
if (rigFoldedHood) rigFoldedHood.visible = raincoat && !hoodUp;
},
updateLighting(altitude: number, ambient: number) {
const settings = CHARACTER.lighting;
const daylight = MathUtils.smoothstep(altitude, settings.nightAltitude, settings.dayAltitude);
const brightness = MathUtils.clamp(MathUtils.lerp(settings.nightBrightness, 1, daylight) * ambient,
settings.minimumBrightness, 1);
// Preserve the flat palette and outlines, always tinting from the original colors.
for (const [material, color] of baseColors) material.color.copy(color).multiplyScalar(brightness);
},
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
const changed = moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote;
previousMoving = moving;
previousEmote = emote;
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
if (emote === 'wave') {
pointArm(umbrellaOpen ? 'ArmLeft' : 'ArmRight',
aim.set(umbrellaOpen ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
} else if (emote === 'pray') {
pointArm('ArmLeft', aim.set(.68, -.22, .64));
pointArm('ArmRight', aim.set(-.68, -.22, .64));
joints.Head.rotateX(.16);
} else if (moving) {
const stride = Math.sin(time * 9) * .4;
joints.LegLeft.rotateX(stride);
joints.LegRight.rotateX(-stride);
joints.ArmLeft.rotateX(-stride * .6);
joints.ArmRight.rotateX(stride * .6);
}
// Keep the grip stable while the free arm and legs perform their own animations.
if (umbrellaOpen) pointArm('ArmRight', aim.set(.05, -.45, .7));
return changed;
},
dispose() {
if (ownsAssets) {
group.traverse(object => { if (object instanceof Mesh) object.geometry.dispose(); });
for (const material of materials) material.dispose();
}
group.removeFromParent();
},
};
}
return bindRig(group, true);
}
+32
View File
@@ -0,0 +1,32 @@
import { MathUtils, Vector3 } from 'three';
import { Sky } from 'three/addons/objects/Sky.js';
import { SCENE } from '../constants/scene.ts';
import { NIGHT_SKY } from '../constants/nightSky.ts';
import { cloudShader, skyCloudFragment } from './clouds.ts';
export function createSky() {
const mesh = new Sky();
const fragment = mesh.material.fragmentShader;
const start = fragment.indexOf('// Clouds');
const end = fragment.indexOf('gl_FragColor = vec4( texColor, 1.0 );', start);
if (start < 0 || end < 0) throw new Error('Sky cloud shader integration needs updating');
const nightBackground = `texColor += vec3(${NIGHT_SKY.backgroundRadiance.map(value => value.toFixed(3)).join(', ')})
* (1.0 - smoothstep(-0.07, 0.05, vSunDirection.y));`;
mesh.material.fragmentShader = cloudShader + fragment.slice(0, start) + nightBackground + skyCloudFragment + fragment.slice(end);
mesh.scale.setScalar(SCENE.skyRadius);
const uniforms = mesh.material.uniforms;
uniforms.turbidity.value = 2.5;
uniforms.rayleigh.value = 2;
uniforms.mieCoefficient.value = SCENE.mieCoefficient;
uniforms.mieDirectionalG.value = SCENE.mieDirectionalG;
uniforms.showSunDisc.value = 0;
uniforms.cloudCoverage.value = 0;
return {
mesh,
update(direction: Vector3, altitude: number, cloudCover = 0) {
uniforms.sunPosition.value.copy(direction).multiplyScalar(SCENE.skyRadius);
uniforms.rayleigh.value = MathUtils.lerp(2.5, 1.7, MathUtils.smoothstep(altitude, -6, 30));
uniforms.cloudCoverage.value = cloudCover;
},
};
}
+155
View File
@@ -0,0 +1,155 @@
import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Group, MathUtils, Mesh, MeshStandardMaterial, Vector3 } from 'three';
import type { Material } from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { STRUCTURES, STRUCTURE_ASSETS } from '../constants/structures.ts';
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
import { summitPosition } from '../controls/summitCoordinates.ts';
export type StructureAssets = Map<string, Group>;
type SupportAnchor = { position: [number, number, number]; postWidth: number };
export async function loadStructures(includeRelief = false): Promise<StructureAssets> {
const loader = new GLTFLoader();
const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id),
...(includeRelief ? [SUMMIT_RELIEF.assetId, SUMMIT_PLATEAU.marker.assetId] : [])];
return new Map(await Promise.all(ids.map(async id => {
const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb');
return [id, model.scene] as const;
})));
}
/** Local WGS84 tangent approximation, sufficient for the < 1 km structure area. +z south. */
export function structurePosition(latitude: number, longitude: number, origin: { latitude: number; longitude: number }) {
const { x, z } = summitPosition(latitude, longitude, origin);
return new Vector3(x, 0, z);
}
export function createStructures(origin: { latitude: number; longitude: number }, groundAt: (x: number, z: number) => number,
assets: StructureAssets) {
const group = new Group();
group.name = 'Refined summit assets (estimated dimensions)';
const geometries = new Set<BufferGeometry>();
const materials = new Set<Material>();
const wall = new MeshStandardMaterial({ color: STRUCTURE_ASSETS.foundationColor, roughness: 1 });
const rail = new MeshStandardMaterial({ color: STRUCTURE_ASSETS.supportColor, roughness: .86 });
materials.add(wall); materials.add(rail);
function box(parent: Group, width: number, height: number, depth: number, x: number, y: number, z: number, material: Material) {
const geometry = new BoxGeometry(width, height, depth);
geometries.add(geometry);
const result = new Mesh(geometry, material);
result.position.set(x, y, z);
parent.add(result);
}
function site(id: string, latitude: number, longitude: number) {
const parent = new Group();
parent.name = id;
parent.position.copy(structurePosition(latitude, longitude, origin));
parent.position.y = groundAt(parent.position.x, parent.position.z);
const model = assets.get(id)!;
model.traverse(object => {
if (object instanceof Mesh) {
geometries.add(object.geometry);
(Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m));
if (object.userData.materialBatch === 'groove') {
const material = object.material as Material;
material.polygonOffset = true;
material.polygonOffsetFactor = -1;
material.polygonOffsetUnits = -1;
}
}
});
parent.add(model);
group.add(parent);
return parent;
}
// Solid foundations span the local terrain relief instead of floating on a single sample.
function foundation(parent: Group, width: number, depth: number) {
const samples = [-width / 2, width / 2].flatMap(x => [-depth / 2, depth / 2].map(z => groundAt(parent.position.x + x, parent.position.z + z)));
const top = Math.max(parent.position.y, ...samples) + .2;
const bottom = Math.min(parent.position.y, ...samples) - .4;
parent.position.y = top;
box(parent, width, top - bottom, depth, 0, -(top - bottom) / 2, 0, wall);
}
for (const spec of STRUCTURES.towers) {
const tower = site(spec.id, spec.latitude, spec.longitude);
tower.userData = { source: spec.source, dimensionsEstimated: true, positionEstimated: spec.estimatedPosition };
foundation(tower, spec.width + 1, spec.width + 1);
}
const radarSpec = STRUCTURES.radar;
const radar = site('weather-radar', radarSpec.latitude, radarSpec.longitude);
radar.userData = { source: radarSpec.source, dimensionsEstimated: true, positionEstimated: true };
foundation(radar, 8, 8);
for (const spec of STRUCTURES.decks) {
const deck = site(spec.id, spec.latitude, spec.longitude);
deck.rotation.y = MathUtils.degToRad(spec.heading);
deck.userData = { source: spec.source, dimensionsEstimated: true, positionEstimated: true };
const modelRoot = assets.get(spec.id)!.getObjectByName(spec.id)!;
const anchors = JSON.parse(modelRoot.userData.terrainSupportAnchorsJson) as SupportAnchor[];
const localGround = (x: number, z: number) => {
const v = new Vector3(x, 0, z).applyAxisAngle(new Vector3(0, 1, 0), deck.rotation.y).add(deck.position);
return groundAt(v.x, v.z);
};
const bottomZ = -spec.length / 2 - spec.stairLength;
const samples = [-spec.width / 2, spec.width / 2].flatMap(x => [-spec.length / 2, 0, spec.length / 2].map(z => localGround(x, z)));
samples.push(localGround(-spec.width / 2, bottomZ) + spec.stairDrop, localGround(spec.width / 2, bottomZ) + spec.stairDrop);
// Only the walking surface needs clearance; buried post feet must not lift the entire deck.
deck.position.y = Math.max(...samples) + STRUCTURES.deck.clearance;
const supports = new Group();
supports.name = 'terrain-supports';
deck.add(supports);
for (const { position: [x, y, z], postWidth } of anchors) {
const bottom = localGround(x, z) - deck.position.y - .1;
const top = y + .02;
if (top > bottom) box(supports, postWidth, top - bottom, postWidth, x, (top + bottom) / 2, z, rail);
}
let wood: Material = wall;
modelRoot.traverse(object => {
if (object instanceof Mesh && object.userData.materialBatch === 'wood0') wood = object.material as Material;
});
function connectEnd(name: string, startZ: number, topY: number, direction: number) {
const config = STRUCTURE_ASSETS.access;
const access = new Group();
access.name = name;
deck.add(access);
const ground = (x: number, z: number) => localGround(x, z) - deck.position.y;
let count = 1;
let landingY = topY;
for (; count <= config.maxSteps; count++) {
const z = startZ + direction * count * config.treadDepth;
landingY = Math.max(ground(-spec.width / 2, z), ground(0, z), ground(spec.width / 2, z)) + .02;
if (Math.abs(topY - landingY) <= count * config.maxRiser) break;
}
count = Math.min(count, config.maxSteps);
const rise = (topY - landingY) / count;
for (let i = 0; i < count; i++) {
const z = startZ + direction * (i + .5) * config.treadDepth;
const y = topY - (i + 1) * rise;
const bottom = Math.min(...[-spec.width / 2, spec.width / 2].flatMap(x =>
[-.5, .5].map(dz => ground(x, z + dz * config.treadDepth)))) - config.groundOverlap;
// Closed footing beneath each tread reaches the slope instead of ending in mid-air.
box(access, spec.width, Math.max(.02, y - .04 - bottom), config.treadDepth,
0, (y - .04 + bottom) / 2, z, wall);
box(access, spec.width, .04, config.treadDepth + .002, 0, y - .02, z, wood);
}
const z0 = startZ + direction * count * config.treadDepth;
const endZ = z0 + direction * config.landingLength;
// The outer landing edge follows the cross-slope too, not just its highest corner.
const vertices = [-spec.width / 2, spec.width / 2].flatMap(x =>
[x, landingY, z0, x, ground(x, endZ) + .02, endZ]);
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));
geometry.setIndex(direction > 0 ? [0, 1, 2, 2, 1, 3] : [0, 2, 1, 2, 3, 1]);
geometry.computeVertexNormals();
geometries.add(geometry);
access.add(new Mesh(geometry, wood));
access.userData = { startZ, endZ, topY, stepCount: count };
}
if (STRUCTURE_ASSETS.access.deckIds.includes(spec.id)) {
connectEnd('entry-access', spec.length / 2, 0, 1);
connectEnd('exit-access', bottomZ, -spec.stairDrop, -1);
}
}
return { group, dispose() { geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); } };
}
+60
View File
@@ -0,0 +1,60 @@
import { BufferAttribute, BufferGeometry } from 'three';
import { SUMMIT_ASSETS } from '../constants/summitAssets.ts';
/** Build-time only: share bit-identical vertices, preserving topology and every attribute. */
export function encodeSummitGeometry(geometry:BufferGeometry) {
const attributes=SUMMIT_ASSETS.attributes.map(([name])=>geometry.getAttribute(name));
const unique:number[]=[],indices:number[]=[],known=new Map<string,number>();
for(let i=0;i<attributes[0].count;i++) {
const key=attributes.flatMap(a=>Array.from(a.array.subarray(i*a.itemSize,(i+1)*a.itemSize))).join(',');
let id=known.get(key);
if(id===undefined) {id=unique.length;known.set(key,id);unique.push(i);}
indices.push(id);
}
const floats=SUMMIT_ASSETS.attributes.reduce((n,[,size])=>n+size,0);
const buffer=new ArrayBuffer(16+unique.length*floats*4+indices.length*4);
new Uint32Array(buffer,0,4).set([SUMMIT_ASSETS.magic,SUMMIT_ASSETS.version,unique.length,indices.length]);
let offset=16;
for(const a of attributes) {
const target=new Float32Array(buffer,offset,unique.length*a.itemSize);
unique.forEach((source,i)=>{for(let j=0;j<a.itemSize;j++)target[i*a.itemSize+j]=a.array[source*a.itemSize+j];});
offset+=target.byteLength;
}
new Uint32Array(buffer,offset).set(indices);
return buffer;
}
export function decodeSummitGeometry(buffer:ArrayBuffer) {
if(buffer.byteLength<16)throw new Error('정상 지면 파일이 불완전합니다.');
const [magic,version,vertices,indices]=new Uint32Array(buffer,0,4);
const floats=SUMMIT_ASSETS.attributes.reduce((n,[,size])=>n+size,0);
if(magic!==SUMMIT_ASSETS.magic||version!==SUMMIT_ASSETS.version||indices%3!==0||
buffer.byteLength!==16+vertices*floats*4+indices*4)throw new Error('정상 지면 파일 형식이 다릅니다.');
const geometry=new BufferGeometry();
let offset=16;
for(const [name,size] of SUMMIT_ASSETS.attributes) {
const values=new Float32Array(buffer,offset,vertices*size);
geometry.setAttribute(name,new BufferAttribute(values,size));offset+=values.byteLength;
}
const index=new Uint32Array(buffer,offset,indices);
if(index.some(value=>value>=vertices))throw new Error('정상 지면 인덱스가 올바르지 않습니다.');
geometry.setIndex(new BufferAttribute(index,1));geometry.computeBoundingSphere();
return geometry;
}
export async function loadSummitAssets():Promise<BufferGeometry> {
const response=await fetch(SUMMIT_ASSETS.manifestUrl);
if(!response.ok)throw new Error('정상 지면 메타데이터를 불러오지 못했습니다.');
const manifest=await response.json() as {version:number;bufferHash:string;byteLength:number};
if(manifest.version!==SUMMIT_ASSETS.version)throw new Error('정상 지면을 다시 생성해 주세요.');
const compressed=typeof DecompressionStream!=='undefined';
const binary=await fetch(`${SUMMIT_ASSETS.bufferUrl}${compressed?'.gz':''}?v=${manifest.bufferHash}`);
if(!binary.ok)throw new Error('정상 지면 파일을 불러오지 못했습니다.');
// Vite/most hosts decode Content-Encoding in fetch; plain static hosts may serve raw gzip.
const alreadyDecoded=binary.headers.get('Content-Encoding')?.toLowerCase().includes('gzip');
const buffer=compressed&&!alreadyDecoded
? await new Response(binary.body!.pipeThrough(new DecompressionStream('gzip'))).arrayBuffer()
: await binary.arrayBuffer();
if(buffer.byteLength!==manifest.byteLength)throw new Error('정상 지면 파일 크기가 다릅니다.');
return decodeSummitGeometry(buffer);
}
+95
View File
@@ -0,0 +1,95 @@
import { BufferGeometry, Float32BufferAttribute } from 'three';
import { SUMMIT_TRAILS } from '../constants/summitTrails.ts';
import { SUMMIT_MATERIALS } from '../constants/summitMaterials.ts';
import { SUMMIT_ASSETS } from '../constants/summitAssets.ts';
import type { createSummitWalking } from '../controls/summitWalking.ts';
/** Offline subdivisions stay on the source terrain; adaptive interiors preserve blend bands. */
export function buildSummitGeometry(walking: ReturnType<typeof createSummitWalking>, terrainGeometry: BufferGeometry, adaptive=false) {
const {radius,surfaceStep:step,surfaceLift:lift}=SUMMIT_TRAILS;
const {start}=walking;
const surfaces = { forest: { vertices: [] as number[], uv: [] as number[], shades: [] as number[], normals: [] as number[] } };
const trailDistances:number[]=[];
const rockDistances:number[]=[];
// Subdivide the actual terrain triangles: a separate regular grid cuts through ridges.
type Vertex = number[];
const position=terrainGeometry.getAttribute('position'),normal=terrainGeometry.getAttribute('normal');
const index=terrainGeometry.getIndex()!;
const vertex=(i:number):Vertex=>[position.getX(i),position.getY(i),position.getZ(i),normal.getX(i),normal.getY(i),normal.getZ(i)];
const midpoint=(a:Vertex,b:Vertex)=>a.map((v,i)=>(v+b[i])/2);
function triangle(a:Vertex,b:Vertex,c:Vertex) {
const x=(a[0]+b[0]+c[0])/3,z=(a[2]+b[2]+c[2])/3;
const edge=Math.max(Math.hypot(a[0]-b[0],a[2]-b[2]),Math.hypot(b[0]-c[0],b[2]-c[2]),Math.hypot(c[0]-a[0],c[2]-a[2]));
if(Math.hypot(x-start.x,z-start.z)>radius+edge) return;
let refine=edge>step*2;
const distance=Math.hypot(x-start.x,z-start.z);
if(adaptive && edge<=SUMMIT_ASSETS.interiorMaxEdge && distance+edge<radius && distance-edge>SUMMIT_ASSETS.detailedStartRadius) {
// Signed distances are 1-Lipschitz: an entire triangle must clear the blend band.
const rock=walking.rockDistance(x,z),path=walking.pathDistance(x,z);
const r=SUMMIT_MATERIALS.rockBlend,t=SUMMIT_MATERIALS.edgeBlend;
const pureRock=rock+edge<r.innerMeters-r.irregularityMeters;
const outsideRock=rock-edge>r.outerMeters+r.irregularityMeters;
const purePath=path+edge<t.innerMeters-t.irregularityMeters;
const pureForest=path-edge>t.outerMeters+t.irregularityMeters;
if(pureRock || outsideRock&&(purePath||pureForest))refine=false;
}
if(refine) {
const ab=midpoint(a,b),bc=midpoint(b,c),ca=midpoint(c,a);
triangle(a,ab,ca);triangle(ab,b,bc);triangle(ca,bc,c);triangle(ab,bc,ca);return;
}
if(!walking.inside(x,z))return;
// All three surfaces share one continuous material; boundaries are sampled per pixel.
const surface='forest',tile=SUMMIT_MATERIALS.forest.tileMeters;
const {vertices,uv,shades,normals}=surfaces[surface];
for(const p of [a,b,c]) {
vertices.push(p[0],p[1]+lift,p[2]);uv.push(p[0]/tile,-p[2]/tile);
if(surface==='forest') trailDistances.push(Math.max(-4,Math.min(4,walking.pathDistance(p[0],p[2]))));
rockDistances.push(Math.max(-4,Math.min(4,walking.rockDistance(p[0],p[2]))));
const length=Math.hypot(p[3],p[4],p[5]);normals.push(p[3]/length,p[4]/length,p[5]/length);
const variation=.94+.06*Math.sin(p[0]*.19)*Math.sin(p[2]*.13);
shades.push(variation,variation,variation);
}
}
for(let i=0;i<index.count;i+=3) {
const ia=index.getX(i),ib=index.getX(i+1),ic=index.getX(i+2);
// Coarse rejection before allocating or subdividing distant triangles.
const xs=[position.getX(ia),position.getX(ib),position.getX(ic)];
const zs=[position.getZ(ia),position.getZ(ib),position.getZ(ic)];
if(Math.min(...xs)>start.x+radius||Math.max(...xs)<start.x-radius||Math.min(...zs)>start.z+radius||Math.max(...zs)<start.z-radius)continue;
triangle(vertex(ia),vertex(ib),vertex(ic));
}
const {vertices,uv,shades,normals}=surfaces.forest;
const geometry=new BufferGeometry();
for(const [name,values,size] of [
['position',vertices,3],['normal',normals,3],['uv',uv,2],['color',shades,3],
['trailDistance',trailDistances,1],['rockDistance',rockDistances,1],
] as const) geometry.setAttribute(name,new Float32BufferAttribute(values,size));
if(!adaptive)return geometry;
const stitched=stitchSummitGeometry(geometry);geometry.dispose();return stitched;
}
/** Close hanging edges with the neighbour's actual vertex, including its shading attributes. */
export function stitchSummitGeometry(source:BufferGeometry) {
const p=source.getAttribute('position');
const key=(x:number,z:number)=>`${Math.round(x*1e6)},${Math.round(z*1e6)}`;
const known=new Map<string,number>();
for(let i=0;i<p.count;i++)known.set(key(p.getX(i),p.getZ(i)),i);
const order:number[]=[];
function triangle(a:number,b:number,c:number) {
for(const [u,v,w] of [[a,b,c],[b,c,a],[c,a,b]]) {
const middle=known.get(key((p.getX(u)+p.getX(v))/2,(p.getZ(u)+p.getZ(v))/2));
if(middle!==undefined && key(p.getX(middle),p.getZ(middle))!==key(p.getX(u),p.getZ(u)) && key(p.getX(middle),p.getZ(middle))!==key(p.getX(v),p.getZ(v))) {
triangle(u,middle,w);triangle(middle,v,w);return;
}
}
order.push(a,b,c);
}
for(let i=0;i<p.count;i+=3)triangle(i,i+1,i+2);
const result=new BufferGeometry();
for(const [name,a] of Object.entries(source.attributes)) {
const values=new Float32Array(order.length*a.itemSize);
order.forEach((sourceIndex,i)=>{for(let j=0;j<a.itemSize;j++)values[i*a.itemSize+j]=a.array[sourceIndex*a.itemSize+j];});
result.setAttribute(name,new Float32BufferAttribute(values,a.itemSize));
}
return result;
}
+25
View File
@@ -0,0 +1,25 @@
import { Group, Mesh, type Material, type BufferGeometry, type MeshStandardMaterial } from 'three';
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
export function createSummitMarker(asset: Group, start: {x: number; z: number},
groundAt: (x: number, z: number) => number, rockMaterial: MeshStandardMaterial) {
const group = new Group();
group.name = SUMMIT_PLATEAU.marker.assetId;
const [x,z] = SUMMIT_PLATEAU.marker.offset;
group.position.set(start.x+x, groundAt(start.x+x,start.z+z), start.z+z);
const granite = rockMaterial.clone();
granite.vertexColors = false;
const materials = new Set<Material>([granite]);
const geometries = new Set<BufferGeometry>();
asset.traverse(object => {
if (!(object instanceof Mesh)) return;
geometries.add(object.geometry);
(Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m));
if (object.name.replaceAll('_',' ') === 'Marker granite body') object.material = granite;
});
group.add(asset);
return { group, dispose() {
geometries.forEach(g => g.dispose());
materials.forEach(m => m.dispose());
} };
}
+80
View File
@@ -0,0 +1,80 @@
import { BoxGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
/** Non-walkable cliff detail and facility stairs; existing terrain remains the walking surface. */
export function createSummitRelief(radar: Vector3, groundAt: (x: number, z: number) => number, rockMaterial: MeshStandardMaterial, rockAsset: Group) {
const group = new Group();
group.name = 'Photo-estimated radar cliffs and access';
const stone = rockMaterial.clone();
stone.onBeforeCompile = rockMaterial.onBeforeCompile;
stone.customProgramCacheKey = rockMaterial.customProgramCacheKey;
stone.color.set(SUMMIT_RELIEF.rockTint);
stone.flatShading = false;
const metal = new MeshStandardMaterial({ color: SUMMIT_RELIEF.access.color, roughness: .72, metalness: .45 });
const ground = (x: number, z: number) => groundAt(radar.x + x, radar.z + z);
// Blender-authored joints, UVs and normals, in metres relative to the radar base.
rockAsset.position.copy(radar);
const replacedMaterials=new Set<import('three').Material>();
rockAsset.traverse(object=>{
if(object instanceof Mesh) {
(Array.isArray(object.material)?object.material:[object.material]).forEach(m=>replacedMaterials.add(m));
stone.vertexColors=object.geometry.hasAttribute('color');
object.material=stone;
}
});
replacedMaterials.forEach(m=>m.dispose());
group.add(rockAsset);
const config=SUMMIT_RELIEF.access;
const start=new Vector3(radar.x+config.bottom[0],ground(...config.bottom)+.15,radar.z+config.bottom[1]);
const end=new Vector3(radar.x+config.top[0],radar.y+.15,radar.z+config.top[1]);
const dx=end.x-start.x,dz=end.z-start.z,length=Math.hypot(dx,dz);
const side=new Vector3(dz/length,0,-dx/length);
const count=Math.ceil(length/config.treadDepth);
const tread=new BoxGeometry(config.width,.09,length/count+.02);
const railGeometry=new CylinderGeometry(config.railRadius,config.railRadius,1,6);
const up=new Vector3(0,1,0);
const beam=(a:Vector3,b:Vector3)=>{
const mesh=new Mesh(railGeometry,metal),direction=b.clone().sub(a);
mesh.position.copy(a).add(b).multiplyScalar(.5);mesh.scale.y=direction.length();
mesh.quaternion.setFromUnitVectors(up,direction.normalize());group.add(mesh);
};
for(let i=0;i<count;i++) {
const mesh=new Mesh(tread,metal);mesh.position.lerpVectors(start,end,(i+.5)/count);
mesh.rotation.y=Math.atan2(dx,dz);group.add(mesh);
}
for(const sign of [-1,1]) {
const a=start.clone().addScaledVector(side,sign*config.width/2),b=end.clone().addScaledVector(side,sign*config.width/2);
beam(a,b);
beam(a.clone().addScaledVector(up,config.railHeight),b.clone().addScaledVector(up,config.railHeight));
for(let i=0;i<=count;i+=config.postEvery) {
const foot=a.clone().lerp(b,i/count);beam(foot,foot.clone().addScaledVector(up,config.railHeight));
}
beam(b,b.clone().addScaledVector(up,config.railHeight));
}
const landingEnd=new Vector3(radar.x+config.landingEnd[0],end.y,radar.z+config.landingEnd[1]);
const landing=new Mesh(new BoxGeometry(end.distanceTo(landingEnd),.09,config.width),metal);
landing.position.copy(end).add(landingEnd).multiplyScalar(.5);group.add(landing);
for(const sign of [-1,1]) {
const offset=new Vector3(0,0,sign*config.width/2);
const a=end.clone().add(offset),b=landingEnd.clone().add(offset);
beam(a,b);beam(a,a.clone().addScaledVector(up,config.railHeight));
beam(b,b.clone().addScaledVector(up,config.railHeight));
beam(a.clone().addScaledVector(up,config.railHeight),b.clone().addScaledVector(up,config.railHeight));
}
// Keep the static repeated treads and rails in one draw call.
const originals=new Set<import('three').BufferGeometry>();
for(const material of [metal]) {
const parts=group.children.filter((o): o is Mesh => o instanceof Mesh && o.material===material);
const transformed=parts.map(mesh=>{mesh.updateMatrix();originals.add(mesh.geometry);return mesh.geometry.clone().applyMatrix4(mesh.matrix);});
const merged=new Mesh(mergeGeometries(transformed),material);
merged.name='Radar access stairs and railing';
parts.forEach(mesh=>group.remove(mesh));transformed.forEach(g=>g.dispose());group.add(merged);
}
originals.forEach(g=>g.dispose());
return { group, dispose() {
const geometries=new Set<import('three').BufferGeometry>();
group.traverse(o=>{if(o instanceof Mesh)geometries.add(o.geometry);});
geometries.forEach(g=>g.dispose());stone.dispose();metal.dispose();
}};
}
+54
View File
@@ -0,0 +1,54 @@
import type { BufferGeometry } from 'three';
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
import { TERRAIN } from '../constants/terrain.ts';
import { structurePosition } from './structures.ts';
import { terrainGeometryHeightAt } from './terrainGeometry.ts';
import type { TerrainMetadata } from './terrainGeometry.ts';
/** Apply only to the Gwanaksan mesh. Original height arrays and reference origin stay unchanged. */
export function applySummitTerrain(geometry: BufferGeometry, meta: TerrainMetadata) {
const start = structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, meta.origin);
// Sample all residuals BEFORE editing any vertex, avoiding order-dependent corrections.
const controls = SUMMIT_TERRAIN.controls.map(control => {
const point = structurePosition(control.latitude, control.longitude, meta.origin);
const original = terrainGeometryHeightAt(geometry, point.x, point.z) + meta.observerSurfaceElevation
+ (point.x * point.x + point.z * point.z) / (2 * TERRAIN.earthRadiusMeters);
return { x: point.x, z: point.z, residual: control.elevation - original };
});
const positions = geometry.getAttribute('position');
const key = (x: number, z: number) => `${Math.round(x * 1000)},${Math.round(z * 1000)}`;
const slab = new Map(SUMMIT_PLATEAU.heights.map(([x,z,height]) => [key(x,z),height]));
const xs = [...new Set(SUMMIT_PLATEAU.heights.map(p=>p[0]))].sort((a,b)=>a-b);
const zs = [...new Set(SUMMIT_PLATEAU.heights.map(p=>p[1]))].sort((a,b)=>a-b);
const authoredHeight = (x:number,z:number) => {
if(x<xs[0] || x>xs.at(-1)! || z<zs[0] || z>zs.at(-1)!)return undefined;
const cell=(axis:number[],v:number)=>{let lo=0,hi=axis.length-1;while(hi-lo>1){const m=(lo+hi)>>1;if(axis[m]<=v)lo=m;else hi=m;}return lo;};
const i=cell(xs,x),j=cell(zs,z),u=(x-xs[i])/(xs[i+1]-xs[i]),v=(z-zs[j])/(zs[j+1]-zs[j]);
const a=slab.get(key(xs[i],zs[j]))!,b=slab.get(key(xs[i+1],zs[j]))!;
const c=slab.get(key(xs[i],zs[j+1]))!,d=slab.get(key(xs[i+1],zs[j+1]))!;
return u+v<=1 ? a*(1-u-v)+b*u+c*v : b*(1-v)+c*(1-u)+d*(u+v-1);
};
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i), z = positions.getZ(i);
const radius = Math.hypot(x - start.x, z - start.z);
if (radius >= SUMMIT_TERRAIN.outerRadius) continue;
let weighted = 0, weights = 0;
for (const control of controls) {
const distanceSquared = (x - control.x) ** 2 + (z - control.z) ** 2;
if (distanceSquared < 1e-8) { weighted = control.residual; weights = 1; break; }
const weight = 1 / (distanceSquared * distanceSquared);
weighted += control.residual * weight;
weights += weight;
}
const t = Math.max(0, (radius - SUMMIT_TERRAIN.coreRadius) / (SUMMIT_TERRAIN.outerRadius - SUMMIT_TERRAIN.coreRadius));
const fade = 1 - t * t * (3 - 2 * t);
positions.setY(i, positions.getY(i) + weighted / weights * fade);
const authored = authoredHeight(x - start.x, z - start.z);
if (authored !== undefined) positions.setY(i, authored - meta.observerSurfaceElevation
- (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters));
}
positions.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
}
+133
View File
@@ -0,0 +1,133 @@
import { BufferGeometry, Color, Group, Mesh, MeshStandardMaterial, RepeatWrapping, ShaderChunk, SRGBColorSpace, TextureLoader, Vector2 } from 'three';
import { SUMMIT_MATERIALS } from '../constants/summitMaterials.ts';
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
/** Illustrative land cover, not photogrammetry. Uses the same corridors as walking. */
export function createSummitTrails(geometry: BufferGeometry, onTextureLoaded: ()=>void = ()=>{}, start={x:0,z:0}) {
const group=new Group();
group.name='Summit trails and estimated land cover (200m)';
const loader=new TextureLoader();
const extraTextures: import('three').Texture[]=[];
let disposed=false;
let rockMaterial: MeshStandardMaterial;
function createMesh() {
const name='forest';
const settings=SUMMIT_MATERIALS.forest;
const load=(kind:string,surface=name)=>{
const texture=loader.load(`${SUMMIT_MATERIALS.baseUrl}/${surface}-${kind}.jpg`,()=>{if(!disposed)onTextureLoaded();});
texture.wrapS=texture.wrapT=RepeatWrapping;
texture.anisotropy=SUMMIT_MATERIALS.anisotropy;
if(kind==='color')texture.colorSpace=SRGBColorSpace;
return texture;
};
const material=new MeshStandardMaterial({color:new Color(settings.tint),vertexColors:true,
map:load('color'),normalMap:load('normal'),roughnessMap:load('roughness'),
normalScale:new Vector2(settings.normalStrength,settings.normalStrength),roughness:1,
polygonOffset:true,polygonOffsetFactor:-1,polygonOffsetUnits:-1});
{
material.color.set('#ffffff');
const pathColor=load('color','path'),pathNormal=load('normal','path'),pathRoughness=load('roughness','path');
extraTextures.push(pathColor,pathNormal,pathRoughness);
const rockColor=load('color','rock'),rockNormal=load('normal','rock'),rockRoughness=load('roughness','rock');
extraTextures.push(rockColor,rockNormal,rockRoughness);
const plateauColor=loader.load(`${SUMMIT_MATERIALS.baseUrl}/plateau-color.png`,()=>{if(!disposed)onTextureLoaded();});
plateauColor.colorSpace=SRGBColorSpace;plateauColor.anisotropy=SUMMIT_MATERIALS.anisotropy;extraTextures.push(plateauColor);
rockMaterial=new MeshStandardMaterial({map:rockColor,normalMap:rockNormal,roughnessMap:rockRoughness,
normalScale:new Vector2(SUMMIT_MATERIALS.rock.normalStrength,SUMMIT_MATERIALS.rock.normalStrength),roughness:1});
const detail=SUMMIT_MATERIALS.rock;
const secondaryUv=`uv*${detail.secondaryScale}+vec2(${detail.secondaryOffset.join(',')})`;
const secondaryBlend=`${detail.secondaryBlend}+${detail.secondaryVariation}*sin(uv.x*0.71+sin(uv.y*0.53))`;
rockMaterial.onBeforeCompile=shader=>{
shader.fragmentShader=`vec4 rockDetail(sampler2D tex,vec2 uv) {
return mix(texture2D(tex,uv),texture2D(tex,${secondaryUv}),${secondaryBlend});
}\n`+shader.fragmentShader;
for(const [chunk,map,uv] of [['map_fragment','map','vMapUv'],['normal_fragment_maps','normalMap','vNormalMapUv'],['roughnessmap_fragment','roughnessMap','vRoughnessMapUv']] as const)
shader.fragmentShader=shader.fragmentShader.replace(`#include <${chunk}>`,ShaderChunk[chunk].replaceAll(`texture2D( ${map}, ${uv} )`,`rockDetail(${map},${uv})`));
};
rockMaterial.customProgramCacheKey=()=>`route-rock-detail-${detail.secondaryScale}-${secondaryBlend}`;
// Keep leaf detail near the observer; avoid obvious repeated bands across distant slopes.
material.onBeforeCompile=shader=>{
shader.uniforms.farGroundColor={value:new Color(SUMMIT_MATERIALS.forest.farColor)};
shader.uniforms.forestTint={value:new Color(SUMMIT_MATERIALS.forest.tint)};
shader.uniforms.trailColor={value:pathColor};shader.uniforms.trailNormal={value:pathNormal};shader.uniforms.trailRoughness={value:pathRoughness};
shader.uniforms.rockColor={value:rockColor};shader.uniforms.rockNormal={value:rockNormal};shader.uniforms.rockRoughness={value:rockRoughness};
shader.uniforms.plateauColor={value:plateauColor};
shader.vertexShader='attribute float trailDistance,rockDistance; varying float vTrailDistance,vRockDistance;\n'+shader.vertexShader;
shader.vertexShader=shader.vertexShader.replace('#include <begin_vertex>','#include <begin_vertex>\nvTrailDistance=trailDistance;vRockDistance=rockDistance;');
const edge=SUMMIT_MATERIALS.edgeBlend;
const rockEdge=SUMMIT_MATERIALS.rockBlend;
shader.fragmentShader=`uniform vec3 farGroundColor,forestTint;
uniform sampler2D trailColor,trailNormal,trailRoughness;
uniform sampler2D rockColor,rockNormal,rockRoughness;
uniform sampler2D plateauColor;
varying float vTrailDistance,vRockDistance;
vec2 summitDx,summitDy;
float summitRock,summitTrail,summitFade;
vec2 rockUv(vec2 uv) { return uv*${(SUMMIT_MATERIALS.forest.tileMeters/SUMMIT_MATERIALS.rock.tileMeters).toFixed(6)}; }
float rockWeight(vec2 uv) {
float scatter=sin(uv.x*9.7+sin(uv.y*11.3))*sin(uv.y*7.1+uv.x*4.3);
return 1.0-smoothstep(${rockEdge.innerMeters},${rockEdge.outerMeters},vRockDistance+scatter*${rockEdge.irregularityMeters});
}
float trailWeight(vec2 uv) {
float scatter=sin(uv.x*13.7+sin(uv.y*8.3))*sin(uv.y*17.1+uv.x*3.1);
return 1.0-smoothstep(${edge.innerMeters},${edge.outerMeters},vTrailDistance+scatter*${edge.irregularityMeters});
}
vec4 summitTex(sampler2D tex,vec2 uv,float scale) {
return textureGrad(tex,uv,summitDx*scale,summitDy*scale);
}
vec4 summitGroundSample(sampler2D tex,vec2 uv) {
float blend=0.5+0.5*sin(uv.x*0.71+sin(uv.y*0.53));
return mix(summitTex(tex,uv,1.0),summitTex(tex,uv*1.37+vec2(0.31,0.73),1.37),blend);
}
vec4 summitRockSample(sampler2D tex,vec2 uv) {
float scale=${(SUMMIT_MATERIALS.forest.tileMeters/SUMMIT_MATERIALS.rock.tileMeters).toFixed(6)};
return mix(summitTex(tex,uv,scale),summitTex(tex,${secondaryUv},scale*${detail.secondaryScale}),${secondaryBlend});
}
// Uniform interiors sample one material; only transition fragments sample neighbours.
vec4 summitSample(sampler2D groundMap,sampler2D dirtMap,sampler2D stoneMap,vec2 uv,int kind) {
vec4 stone=vec4(0.0),dirt=vec4(0.0);
if(summitRock>0.0) {
stone=summitRockSample(stoneMap,rockUv(uv));
vec2 local=vec2(uv.x,-uv.y)*${SUMMIT_MATERIALS.forest.tileMeters.toFixed(1)}-vec2(${start.x.toFixed(6)},${start.z.toFixed(6)});
vec2 atlas=(local-vec2(${SUMMIT_PLATEAU.atlasBounds.slice(0,2).join(',')}))/vec2(${SUMMIT_PLATEAU.atlasBounds[2]-SUMMIT_PLATEAU.atlasBounds[0]},${SUMMIT_PLATEAU.atlasBounds[3]-SUMMIT_PLATEAU.atlasBounds[1]});
float atlasMask=smoothstep(0.0,0.035,min(min(atlas.x,atlas.y),min(1.0-atlas.x,1.0-atlas.y)));
if(kind==0)stone.rgb=mix(stone.rgb,texture2D(plateauColor,vec2(atlas.x,1.0-atlas.y)).rgb,atlasMask*0.95);
if(kind==1)stone.xy=mix(stone.xy,vec2(0.5)+(stone.xy-0.5)*0.32,atlasMask);
if(kind==1)stone.xy=(stone.xy-0.5)*${SUMMIT_MATERIALS.rock.normalStrength/SUMMIT_MATERIALS.forest.normalStrength}+0.5;
if(summitRock>=1.0)return stone;
}
if(summitTrail>0.0) {
dirt=summitTex(dirtMap,uv,1.0);
if(kind==1)dirt.xy=(dirt.xy-0.5)*${SUMMIT_MATERIALS.path.normalStrength/SUMMIT_MATERIALS.forest.normalStrength}+0.5;
if(summitTrail>=1.0)return mix(dirt,stone,summitRock);
}
vec4 ground=summitGroundSample(groundMap,uv);
if(kind==0)ground.rgb=mix(ground.rgb*forestTint,farGroundColor,summitFade);
return mix(mix(ground,dirt,summitTrail),stone,summitRock);
}\n`+shader.fragmentShader;
shader.fragmentShader=shader.fragmentShader.replace('#include <map_fragment>',
`// Derivatives are computed before divergent branches to preserve mip selection at edges.
summitDx=dFdx(vMapUv);summitDy=dFdy(vMapUv);
summitRock=rockWeight(vMapUv);summitTrail=trailWeight(vMapUv);
summitFade=smoothstep(${SUMMIT_MATERIALS.forest.fadeNear.toFixed(1)},${SUMMIT_MATERIALS.forest.fadeFar.toFixed(1)},length(vViewPosition))*0.9;
${ShaderChunk.map_fragment.replace('texture2D( map, vMapUv )','summitSample(map,trailColor,rockColor,vMapUv,0)')}`);
shader.fragmentShader=shader.fragmentShader.replace('#include <normal_fragment_maps>',
ShaderChunk.normal_fragment_maps.replaceAll('texture2D( normalMap, vNormalMapUv )','summitSample(normalMap,trailNormal,rockNormal,vNormalMapUv,1)'));
shader.fragmentShader=shader.fragmentShader.replace('#include <roughnessmap_fragment>',
ShaderChunk.roughnessmap_fragment.replace('texture2D( roughnessMap, vRoughnessMapUv )','summitSample(roughnessMap,trailRoughness,rockRoughness,vRoughnessMapUv,2)'));
};
}
const mesh=new Mesh(geometry,material);mesh.name=`Summit ${name} PBR surface`;group.add(mesh);
return mesh;
}
const meshes=[createMesh()];
return {group,rockMaterial:rockMaterial!,dispose(){
disposed=true;
rockMaterial.dispose();
extraTextures.forEach(texture=>texture.dispose());
for(const {geometry,material} of meshes) {
geometry.dispose();material.map?.dispose();material.normalMap?.dispose();material.roughnessMap?.dispose();material.dispose();
}
}};
}
+71
View File
@@ -0,0 +1,71 @@
import { AdditiveBlending, Color, DirectionalLight, Group, MathUtils, Mesh, PlaneGeometry, ShaderMaterial, Vector3 } from 'three';
import { SCENE } from '../constants/scene.ts';
import { cloudShader } from './clouds.ts';
/** Optical geometry only: the supplied unit direction is never changed here. */
export function createSun(eye: Vector3) {
const group = new Group();
const color = new Color();
const warm = new Color('#ff7835');
const white = new Color('#fff9df');
const radiance = { value: SCENE.sunRadianceAtHorizon as number };
const transmission = { value: 1 };
const cloudCoverage = { value: 0 };
function layer(diameter: number, halo: boolean) {
const size = 2 * SCENE.sunDistance * Math.tan(MathUtils.degToRad(diameter / 2));
const material = new ShaderMaterial({
uniforms: { tint: { value: color }, halo: { value: halo }, radiance, transmission, cloudCoverage,
haloOpacity: { value: SCENE.haloOpacity } },
vertexShader: `varying vec2 vUv; varying vec3 vCloudDirection;
void main() { vUv = uv; vec4 world = modelMatrix * vec4(position, 1.0);
vCloudDirection = world.xyz - cameraPosition;
gl_Position = projectionMatrix * viewMatrix * world; }`,
fragmentShader: `${cloudShader}
uniform vec3 tint; uniform bool halo; uniform float cloudCoverage;
uniform float radiance; uniform float haloOpacity; uniform float transmission;
varying vec2 vUv; varying vec3 vCloudDirection;
void main() {
float r = length(vUv - 0.5) * 2.0;
float alpha = halo ? exp(-r * r * 11.0) * (1.0 - smoothstep(0.6, 1.0, r)) * haloOpacity
: 1.0 - smoothstep(0.96, 1.0, r);
gl_FragColor = vec4(tint * (halo ? 1.0 : radiance), alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
// Fade after tone mapping too: a bright HDR disc must not stay white in overcast skies.
vec3 direction = normalize(vCloudDirection);
float cloud = weatherCloudOpacity(direction, cloudCoverage, weatherCloudField(direction));
gl_FragColor.a *= transmission * (1.0 - cloud);
}`,
transparent: true,
depthWrite: false,
...(halo ? { blending: AdditiveBlending } : {}),
});
const mesh = new Mesh(new PlaneGeometry(size, size), material);
mesh.renderOrder = halo ? 1 : 2;
group.add(mesh);
return mesh;
}
layer(SCENE.haloDiameterDegrees, true);
layer(SCENE.sunDiameterDegrees, false);
const light = new DirectionalLight();
light.target.position.copy(eye);
return {
group, light,
update(direction: Vector3, altitude: number, weatherTransmission = 1, coverage = 0) {
transmission.value = weatherTransmission;
cloudCoverage.value = coverage;
group.position.copy(eye).addScaledVector(direction, SCENE.sunDistance);
group.lookAt(eye);
// Terrain (and the distant reference Earth) occludes the disc by depth.
// A 0° cutoff would incorrectly hide sunrise seen from an elevated surface.
color.copy(warm).lerp(white, MathUtils.smoothstep(altitude, 0, 18));
// Visual exposure approximation: retain warm disc color near sunrise,
// then approach the bright daytime disc without changing its angular size.
radiance.value = MathUtils.lerp(SCENE.sunRadianceAtHorizon, SCENE.sunRadianceAtDay,
MathUtils.smoothstep(altitude, 0, SCENE.sunRadianceDayAltitude)) * weatherTransmission;
light.color.copy(color);
light.intensity = 2 * MathUtils.smoothstep(altitude, -1, 25) * weatherTransmission;
light.position.copy(group.position);
},
};
}
+42
View File
@@ -0,0 +1,42 @@
import { BufferAttribute, Color, Mesh, MeshStandardMaterial } from 'three';
import { TERRAIN } from '../constants/terrain.ts';
import { observer } from '../config/observer.ts';
import { createTerrainGeometry, terrainGeometryHeightAt, validateTerrain } from './terrainGeometry.ts';
import type { TerrainMetadata } from './terrainGeometry.ts';
import { applyCoastalTerrain } from './coast.ts';
import type { LoadedCoast } from './coast.ts';
import { applySummitTerrain } from './summitTerrain.ts';
export async function loadTerrain(paths: { metadataUrl: string; heightsUrl: string } = TERRAIN, origin = observer) {
const responses = await Promise.all([fetch(paths.metadataUrl), fetch(paths.heightsUrl)]);
if (responses.some(response => !response.ok)) throw new Error('지형 파일을 내려받지 못했습니다.');
const [metadata, buffer] = await Promise.all([
responses[0].json() as Promise<TerrainMetadata>, responses[1].arrayBuffer(),
]);
const heights = new Float32Array(buffer);
validateTerrain(metadata, heights, origin);
return { metadata, heights };
}
export function createTerrain(data: Awaited<ReturnType<typeof loadTerrain>>, coast?: LoadedCoast, reconstructSummit = false) {
const geometry = createTerrainGeometry(data.metadata, data.heights, true);
if (reconstructSummit && !coast) applySummitTerrain(geometry, data.metadata);
const positions = geometry.getAttribute('position');
const normals = geometry.getAttribute('normal');
const colors = new Float32Array(positions.count * 3);
const low = new Color('#596047'), high = new Color('#7d8167'), rock = new Color('#94958a');
const color = new Color();
for (let i = 0; i < positions.count; i++) {
const relativeHeight = Math.min(1, Math.max(0, (positions.getY(i) + data.metadata.observerSurfaceElevation) / 650));
const slope = Math.min(1, (1 - normals.getY(i)) * 2.5);
color.copy(low).lerp(high, relativeHeight).lerp(rock, slope);
color.toArray(colors, i * 3);
}
geometry.setAttribute('color', new BufferAttribute(colors, 3));
// Color is illustrative elevation/slope shading, not aerial imagery or land cover.
const material = new MeshStandardMaterial({ vertexColors: true, roughness: 1, metalness: 0 });
if (coast) applyCoastalTerrain(material, coast);
const mesh = new Mesh(geometry, material);
mesh.name = 'Copernicus GLO-30 terrain';
return { mesh, heightAt: (x: number, z: number) => terrainGeometryHeightAt(geometry, x, z), dispose() { geometry.dispose(); material.dispose(); } };
}
+138
View File
@@ -0,0 +1,138 @@
import { BufferAttribute, BufferGeometry } from 'three';
import { TERRAIN } from '../constants/terrain.ts';
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
import { structurePosition } from './structures.ts';
export interface TerrainMetadata {
version: number;
dataset: string;
surfaceType: string;
verticalDatum: string;
origin: { latitude: number; longitude: number };
width: number;
height: number;
spacingMeters: number;
radiusMeters: number;
observerSurfaceElevation: number;
minElevation: number;
maxElevation: number;
geographicCorners: [number, number][];
sources: { file: string; bytes: number; sha256: string; url: string }[];
attribution: string;
}
export function validateTerrain(meta: TerrainMetadata, heights: Float32Array, origin: TerrainMetadata['origin']) {
if (meta.version !== 1 || meta.width < 2 || meta.height < 2 ||
!Number.isInteger(meta.width) || !Number.isInteger(meta.height) ||
meta.width !== meta.height || meta.width % 2 !== 1 ||
!Number.isFinite(meta.spacingMeters) || meta.spacingMeters <= 0 ||
meta.radiusMeters !== (meta.width - 1) * meta.spacingMeters / 2 ||
heights.length !== meta.width * meta.height ||
!Number.isFinite(meta.observerSurfaceElevation) ||
heights.some(value => !Number.isFinite(value) || value < -500 || value > 9000)) {
throw new Error('지형 격자 크기·높이 데이터가 올바르지 않습니다.');
}
if (Math.abs(meta.origin.latitude - origin.latitude) > 1e-8 ||
Math.abs(meta.origin.longitude - origin.longitude) > 1e-8) {
throw new Error('관측점 좌표가 지형 원점과 다릅니다. 지형 데이터를 다시 생성해 주세요.');
}
if (Math.abs(terrainHeightAt(meta, heights, 0, 0) - meta.observerSurfaceElevation) > 0.01) {
throw new Error('관측점 표면 높이가 지형 데이터와 다릅니다.');
}
}
/** Local east (x), south (z) in meters → source EGM2008 surface elevation. */
export function terrainHeightAt(meta: TerrainMetadata, heights: Float32Array, x: number, z: number): number {
const col = (x + meta.radiusMeters) / meta.spacingMeters;
const row = (z + meta.radiusMeters) / meta.spacingMeters;
if (col < 0 || row < 0 || col > meta.width - 1 || row > meta.height - 1) throw new RangeError('Outside terrain coverage');
const left = Math.min(Math.floor(col), meta.width - 2);
const top = Math.min(Math.floor(row), meta.height - 2);
const u = col - left, v = row - top;
const offset = top * meta.width + left;
return (1 - v) * ((1 - u) * heights[offset] + u * heights[offset + 1])
+ v * ((1 - u) * heights[offset + meta.width] + u * heights[offset + meta.width + 1]);
}
export function createTerrainGeometry(meta: TerrainMetadata, heights: Float32Array, refine = false): BufferGeometry {
// Keep every source sample. Only insert vertices near the observation origin.
const axis: number[] = [];
for (let c = 0; c < meta.width - 1; c++) {
const x = c * meta.spacingMeters - meta.radiusMeters;
const segments = refine && Math.abs(x + meta.spacingMeters / 2) < TERRAIN.refinementRadiusMeters ? TERRAIN.refinementSegments : 1;
for (let j = 0; j < segments; j++) axis.push(x + j * meta.spacingMeters / segments);
}
axis.push(meta.radiusMeters);
if (refine) {
const start = structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, meta.origin);
// Retain the source lattice and insert the Blender sculpt's half-metre samples.
const detailed = new Set(axis);
for (const [x,z] of SUMMIT_PLATEAU.heights) {
for (const value of [start.x+x,start.z+z]) {
const snapped = Math.round(value * 1000) / 1000;
if (Math.abs(snapped) < meta.radiusMeters) detailed.add(snapped);
}
}
axis.splice(0,axis.length,...[...detailed].sort((a,b)=>a-b));
}
const width = axis.length;
const sample = (c: number, r: number) => heights[Math.max(0, Math.min(meta.height - 1, r)) * meta.width + Math.max(0, Math.min(meta.width - 1, c))];
const cubic = (a: number, b: number, c: number, d: number, t: number) => {
const slope = (u: number, v: number) => u*v <= 0 ? 0 : 2*u*v/(u+v);
const m0 = slope(b-a,c-b), m1 = slope(c-b,d-c);
return (2*t*t*t-3*t*t+1)*b + (t*t*t-2*t*t+t)*m0
+ (-2*t*t*t+3*t*t)*c + (t*t*t-t*t)*m1;
};
const elevation = (x: number, z: number) => {
const u = (x + meta.radiusMeters) / meta.spacingMeters, v = (z + meta.radiusMeters) / meta.spacingMeters;
const c = Math.floor(u), r = Math.floor(v), tx = u-c, tz = v-r;
const rows = [-1,0,1,2].map(d => cubic(sample(c-1,r+d),sample(c,r+d),sample(c+1,r+d),sample(c+2,r+d),tx));
const corners = [sample(c,r),sample(c+1,r),sample(c,r+1),sample(c+1,r+1)];
return Math.max(Math.min(...corners), Math.min(Math.max(...corners), cubic(rows[0],rows[1],rows[2],rows[3],tz)));
};
const positions = new Float32Array(width * width * 3);
for (let row = 0; row < width; row++) {
for (let col = 0; col < width; col++) {
const index = row * width + col;
const x = axis[col], z = axis[row];
const curvature = (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
const originalCol = (x + meta.radiusMeters) / meta.spacingMeters;
const originalRow = (z + meta.radiusMeters) / meta.spacingMeters;
const h = refine ? (Number.isInteger(originalCol) && Number.isInteger(originalRow)
? sample(originalCol,originalRow) : elevation(x,z)) : heights[index];
positions.set([x, h - meta.observerSurfaceElevation - curvature, z], index * 3);
}
}
const indices = new Uint32Array((width - 1) * (width - 1) * 6);
let offset = 0;
for (let row = 0; row < width - 1; row++) {
for (let col = 0; col < width - 1; col++) {
const a = row * width + col, b = a + 1, c = a + width, d = c + 1;
indices.set([a, c, b, b, c, d], offset);
offset += 6;
}
}
const geometry = new BufferGeometry();
geometry.setAttribute('position', new BufferAttribute(positions, 3));
geometry.setIndex(new BufferAttribute(indices, 1));
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
geometry.userData.axis = axis;
return geometry;
}
/** Match the refined rendered triangles so eye height stays above the visible ground. */
export function terrainGeometryHeightAt(geometry: BufferGeometry, x: number, z: number) {
const axis = geometry.userData.axis as number[];
const cell = (value: number) => {
let lo = 0, hi = axis.length - 1;
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (axis[mid] <= value) lo = mid; else hi = mid; }
return lo;
};
const col = cell(x), row = cell(z), width = axis.length;
const u = (x-axis[col])/(axis[col+1]-axis[col]), v = (z-axis[row])/(axis[row+1]-axis[row]);
const p = geometry.getAttribute('position'), k = row*width+col;
const a=p.getY(k), b=p.getY(k+1), c=p.getY(k+width), d=p.getY(k+width+1);
return u+v <= 1 ? a*(1-u-v)+b*u+c*v : b*(1-v)+c*(1-u)+d*(u+v-1);
}
+487
View File
@@ -0,0 +1,487 @@
import { type BufferGeometry, ACESFilmicToneMapping, Color, Fog, HemisphereLight, MathUtils, PerspectiveCamera, Raycaster, Scene, Vector2, Vector3, WebGLRenderer } from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import type { SunState } from '../astronomy/sunCalculator.ts';
import { directionFromAngles } from '../astronomy/direction.ts';
import { observer } from '../config/observer.ts';
import { SCENE } from '../constants/scene.ts';
import { EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts';
import { MOVEMENT } from '../constants/movement.ts';
import { createFpsCameraController } from '../controls/fpsCameraController.ts';
import { createSky } from './sky.ts';
import { createSun } from './sun.ts';
import { createNightSky } from './nightSky.ts';
import { createHorizon } from './horizon.ts';
import { createCompass } from '../ui/compass.ts';
import { createTerrain } from './terrain.ts';
import type { loadTerrain } from './terrain.ts';
import { TERRAIN } from '../constants/terrain.ts';
import { createStructures, structurePosition } from './structures.ts';
import type { StructureAssets } from './structures.ts';
import { createBakedBuildings } from './buildingAssets.ts';
import type { BuildingAssets } from './buildingAssets.ts';
import { BUILDINGS } from '../constants/buildings.ts';
import { walkingCoordinates, walkStep } from '../controls/walking.ts';
import { createOcean, isCoastalLand, seaHeight } from './coast.ts';
import type { LoadedCoast } from './coast.ts';
import { COAST } from '../constants/coast.ts';
import { GISAMUN } from '../constants/gisamun.ts';
import { createHarbour, isGisamun } from './harbour.ts';
import { createRenderInvalidation } from './renderInvalidation.ts';
import { createTerrainMinimap } from '../ui/terrainMinimap.ts';
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
import { createSummitWalking } from '../controls/summitWalking.ts';
import { createSummitTrails } from './summitTrails.ts';
import { createSummitRelief } from './summitRelief.ts';
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
import { createSummitMarker } from './summitMarker.ts';
import { WEATHER } from '../constants/weather.ts';
import { weatherAppearance } from '../weather/weather.ts';
import type { WeatherSample } from '../weather/weather.ts';
import { createPrecipitation } from './precipitation.ts';
import type { createRenderProfile } from '../dev/renderProfile.ts';
import type { loadSheep } from './sheep.ts';
import { canPray, createEmoteState, type Emote } from '../controls/emotes.ts';
import { createRainGearState, type RainGearItem } from '../controls/rainGear.ts';
import { faceTravel, updateThirdPersonCamera } from '../controls/thirdPersonCamera.ts';
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<ReturnType<typeof loadTerrain>>, buildingData: BuildingAssets | null,
onMove: (location: { latitude: number; longitude: number; elevation: number }) => void, coast?: LoadedCoast, structureAssets?: StructureAssets, reconstructSummit = false, summitGeometry?: BufferGeometry,
sheep?: Awaited<ReturnType<typeof loadSheep>>) {
const scene = new Scene();
const rendering = createRenderInvalidation();
const camera = new PerspectiveCamera(SCENE.fieldOfView, innerWidth / innerHeight, SCENE.near, SCENE.far);
camera.position.set(0, observer.eyeHeight, 0);
camera.lookAt(1, observer.eyeHeight, 0);
const renderer = new WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = ACESFilmicToneMapping;
const sky = createSky();
const sun = createSun(camera.position);
const nightSky = createNightSky(camera.position);
const horizon = createHorizon(camera.position, terrainData.metadata.observerSurfaceElevation);
const terrain = createTerrain(terrainData, coast, reconstructSummit);
const remotePlayers = sheep ? createRemotePlayers(sheep, scene, terrain.heightAt) : undefined;
let networkBlocked = false;
let sharedNow: (() => number) | null = null;
let playerMoving = false;
const profiling = import.meta.env.DEV && new URLSearchParams(location.search).has('profile');
let profile: ReturnType<typeof createRenderProfile> | undefined;
let disposed = false;
const summitStarted = performance.now();
const summitWalking = reconstructSummit && !coast ? createSummitWalking(terrainData.metadata.origin) : undefined;
const summitTrails = summitWalking && summitGeometry ? createSummitTrails(summitGeometry,()=>{
rendering.invalidate(); profile?.transition();
}, summitWalking.start) : undefined;
if(profiling) console.info('summit-construction-ms',performance.now()-summitStarted);
const minimap = createTerrainMinimap(terrainData, terrain.mesh.geometry, summitWalking);
const compass = createCompass();
const ocean = coast ? createOcean(coast, terrainData.metadata.observerSurfaceElevation) : undefined;
const harbour = coast && isGisamun(coast.data.origin) ? createHarbour(terrainData.metadata.observerSurfaceElevation) : undefined;
const precipitation = createPrecipitation((x, z) => {
const radius = terrainData.metadata.radiusMeters;
const ground = harbour?.heightAt(x, z) ?? terrain.heightAt(MathUtils.clamp(x, -radius, radius), MathUtils.clamp(z, -radius, radius));
return coast ? Math.max(ground, seaHeight(x, z, terrainData.metadata.observerSurfaceElevation)) : ground;
});
scene.add(precipitation.group);
const buildings = buildingData ? createBakedBuildings(buildingData, terrainData.metadata) : undefined;
buildings?.spill.applyTo(terrain.mesh.material);
const composer = buildings ? new EffectComposer(renderer) : undefined;
const renderPass = new RenderPass(scene, camera);
const bloom = buildings ? new UnrealBloomPass(new Vector2(innerWidth, innerHeight),
BUILDINGS.lighting.bloomStrength, BUILDINGS.lighting.bloomRadius, BUILDINGS.lighting.bloomThreshold) : undefined;
const outputPass = composer ? new OutputPass() : undefined;
if (composer && bloom && outputPass) {
composer.renderTarget1.samples = composer.renderTarget2.samples = 4;
composer.addPass(renderPass);
composer.addPass(bloom);
composer.addPass(outputPass);
}
const start = harbour ? GISAMUN.viewpoint : coast ? { x: 0, z: 0 }
: structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, terrainData.metadata.origin);
const target = harbour ? new Vector3(GISAMUN.jodo.x, terrain.heightAt(GISAMUN.jodo.x, GISAMUN.jodo.z), GISAMUN.jodo.z)
: buildings?.target ?? structurePosition(coast!.lookTarget.latitude, coast!.lookTarget.longitude, terrainData.metadata.origin);
const overviewCamera = camera.clone();
const followCamera = camera.clone();
if (coast) overviewCamera.position.set(0, COAST.overviewHeight, 1200);
else overviewCamera.position.y = BUILDINGS.overviewHeight;
overviewCamera.lookAt(coast ? new Vector3(300, 0, 0) : target);
let overview = false;
const viewNote = document.getElementById('view-note')!;
const restoreView = () => { overview = false; viewNote.textContent = ''; refreshEmotes(); };
canvas.addEventListener('pointerdown', restoreView);
terrain.mesh.updateMatrixWorld(true);
const groundRay = new Raycaster(new Vector3(), new Vector3(0, -1, 0));
const structures = coast ? undefined : createStructures(terrainData.metadata.origin, (x, z) => {
groundRay.ray.origin.set(x, 10000, z);
const hit = groundRay.intersectObject(terrain.mesh, false)[0];
if (!hit) throw new Error('구조물 위치의 지형 높이를 찾지 못했습니다.');
return hit.point.y;
}, structureAssets!);
const summitRelief = summitTrails && structures ? createSummitRelief(
structures.group.getObjectByName('weather-radar')!.position, terrain.heightAt, summitTrails.rockMaterial,
structureAssets!.get(SUMMIT_RELIEF.assetId)!) : undefined;
const summitMarker = summitTrails ? createSummitMarker(structureAssets!.get(SUMMIT_PLATEAU.marker.assetId)!,
start, terrain.heightAt, summitTrails.rockMaterial) : undefined;
const ambient = new HemisphereLight('#c7def0', '#3e4235', 1.5);
const fog = new Fog('#a6b6bc', TERRAIN.fogNear, TERRAIN.fogFar);
scene.fog = fog;
const nightFog = new Color('#060b15'), dawnFog = new Color('#383e38'), dayFog = new Color('#a6b6bc');
let solarState: SunState | undefined;
let weatherTarget = weatherAppearance(null);
const atmosphere = { ...weatherTarget };
const sunDirection = new Vector3();
function applyAtmosphere() {
if (!solarState) return;
const altitude = solarState.altitude;
sunDirection.set(...directionFromAngles(solarState.azimuth, altitude));
sky.update(sunDirection, altitude, atmosphere.cloud);
sun.update(sunDirection, altitude, atmosphere.transmission, atmosphere.cloud);
nightSky.update(solarState.date, {
...observer, ...walkingCoordinates(camera.position.x, camera.position.z, terrainData.metadata.origin),
}, altitude, atmosphere.cloud);
fog.near = atmosphere.fogNear;
fog.far = atmosphere.fogFar;
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(altitude, -8, 15)) * atmosphere.ambient;
sheep?.updateLighting(altitude, atmosphere.ambient);
}
scene.add(sky.mesh, sun.group, sun.light, sun.light.target, horizon.group, terrain.mesh, ambient);
scene.add(nightSky.group);
if (structures) scene.add(structures.group);
if (summitTrails) scene.add(summitTrails.group);
if (summitRelief) scene.add(summitRelief.group);
if (summitMarker) scene.add(summitMarker.group);
if (buildings) scene.add(buildings.group);
if (ocean) scene.add(ocean.mesh);
if (harbour) scene.add(harbour.group);
const destinationRay = new Raycaster();
const pointer = new Vector2();
const controls = createFpsCameraController(camera, canvas, (x, y) => {
const bounds = canvas.getBoundingClientRect();
pointer.set((x - bounds.left) / bounds.width * 2 - 1, -(y - bounds.top) / bounds.height * 2 + 1);
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
const view = sheep ? followCamera : camera;
view.updateMatrixWorld(true);
destinationRay.setFromCamera(pointer, view);
const surfaces = harbour ? [terrain.mesh, harbour.group] : [terrain.mesh];
const hit = destinationRay.intersectObjects(surfaces, true)[0];
return hit?.point ?? null;
});
if (sheep) scene.add(sheep.group);
const emotes = createEmoteState();
const emoteEvents = new AbortController();
const waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!;
const prayButton = document.querySelector<HTMLButtonElement>('#emote-pray')!;
const emoteStatus = document.getElementById('emote-status')!;
const rainGear = createRainGearState();
const umbrellaButton = document.querySelector<HTMLButtonElement>('#gear-umbrella')!;
const raincoatButton = document.querySelector<HTMLButtonElement>('#gear-raincoat')!;
const hoodButton = document.querySelector<HTMLButtonElement>('#gear-hood')!;
const gearStatus = document.getElementById('gear-status')!;
const refreshGear = () => {
umbrellaButton.disabled = raincoatButton.disabled = hoodButton.disabled = !sheep || overview || networkBlocked;
for (const button of [umbrellaButton, raincoatButton, hoodButton]) {
const reason = !rainGear.available ? EQUIPMENT_NOTICE.weather
: button === hoodButton && !rainGear.raincoat ? EQUIPMENT_NOTICE.raincoat : '';
button.setAttribute('aria-disabled', String(Boolean(reason) || button.disabled));
button.dataset.unavailableReason = reason;
}
umbrellaButton.setAttribute('aria-pressed', String(rainGear.umbrella));
raincoatButton.setAttribute('aria-pressed', String(rainGear.raincoat));
hoodButton.setAttribute('aria-pressed', String(rainGear.hood));
umbrellaButton.setAttribute('aria-label', rainGear.umbrella ? '우산 접기' : '우산 쓰기');
raincoatButton.setAttribute('aria-label', rainGear.raincoat ? '우비 벗기' : '우비 입기');
hoodButton.setAttribute('aria-label', rainGear.hood ? '모자 벗기' : '모자 쓰기');
[umbrellaButton, raincoatButton, hoodButton].forEach((button, index) => {
button.title = `${button.getAttribute('aria-label')} · 단축키 ${index + 3}`;
});
const worn = [rainGear.umbrella ? '우산' : '', rainGear.raincoat ? '우비' : '', rainGear.hood ? '후드' : ''].filter(Boolean).join(' · ');
gearStatus.textContent = overview ? '관측 시점으로 돌아오면 장비를 바꿀 수 있습니다'
: !rainGear.available ? EQUIPMENT_NOTICE.weather
: worn ? `${worn} 착용 중 · 이동과 감정표현 중에도 유지`
: '비·눈이 오는 중 · 3 우산 / 4 우비';
};
const toggleGear = (item: RainGearItem) => {
if (!sheep || overview || networkBlocked || !rainGear.toggle(item)) return;
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood);
refreshGear(); rendering.invalidate();
};
umbrellaButton.addEventListener('click', () => toggleGear('umbrella'), { signal: emoteEvents.signal });
raincoatButton.addEventListener('click', () => toggleGear('raincoat'), { signal: emoteEvents.signal });
hoodButton.addEventListener('click', () => toggleGear('hood'), { signal: emoteEvents.signal });
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
const refreshEmotes = () => {
refreshGear();
waveButton.disabled = !sheep || overview;
prayButton.disabled = !sheep || overview;
prayButton.setAttribute('aria-disabled', String(prayButton.disabled || !prayerAvailable()));
prayButton.dataset.unavailableReason = prayerAvailable() ? '' : PRAYER_NOTICE;
waveButton.setAttribute('aria-pressed', String(emotes.current === 'wave'));
prayButton.setAttribute('aria-pressed', String(emotes.current === 'pray'));
emoteStatus.textContent = overview ? '조감 보기에서는 감정표현을 사용할 수 없습니다'
: emotes.current === 'wave' ? '손 흔드는 중 · 이동하면 종료'
: emotes.current === 'pray' ? '일출을 보며 소원 비는 중 · 이동하면 종료'
: prayerAvailable() ? '소원빌기 가능 · 1 인사 / 2 소원빌기 · 이동하면 종료'
: '소원빌기는 선택한 시각이 일출 전후 5분일 때 활성화됩니다';
};
const startEmote = (emote: Exclude<Emote, 'idle'>) => {
if (!sheep || overview || networkBlocked || !emotes.start(emote, prayerAvailable())) return;
controls.clear();
if (emote === 'pray' && solarState) {
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
}
refreshEmotes();
rendering.invalidate();
};
waveButton.addEventListener('click', () => startEmote('wave'), { signal: emoteEvents.signal });
prayButton.addEventListener('click', () => startEmote('pray'), { signal: emoteEvents.signal });
window.addEventListener('keydown', event => {
if (!controls.active() || event.repeat || !['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5'].includes(event.code)) return;
event.preventDefault();
if (['Digit3', 'Digit4', 'Digit5'].includes(event.code)) {
toggleGear(event.code === 'Digit3' ? 'umbrella' : event.code === 'Digit4' ? 'raincoat' : 'hood'); return;
}
startEmote(event.code === 'Digit1' ? 'wave' : 'pray');
}, { signal: emoteEvents.signal });
let eyeHeight = observer.eyeHeight;
let walkedDistance = 0;
const placeObserver = () => {
const { x, z } = camera.position;
const sampled = harbour?.heightAt(x,z) ?? terrain.heightAt(x, z);
const ground = coast ? Math.max(sampled, seaHeight(x, z, terrainData.metadata.observerSurfaceElevation) + 0.05) : sampled;
camera.position.y = ground + eyeHeight;
const elevation = ground + terrainData.metadata.observerSurfaceElevation + (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
horizon.move(camera.position, elevation, eyeHeight);
sun.light.target.position.copy(camera.position);
sky.mesh.position.copy(camera.position);
const coordinates = walkingCoordinates(x, z, terrainData.metadata.origin);
onMove({ ...coordinates, elevation });
document.getElementById('movement-status')!.textContent = `시작점에서 ${Math.hypot(x-start.x, z-start.z).toFixed(0)}m · ${summitWalking ? `걸은 거리 ${walkedDistance.toFixed(0)}m · 반경 200m 길/정상 암반 내 이동 · 구역 경계 추정` : harbour ? '북쪽 방파제 진입부 · 임시 관측점' : coast ? '해안 육지 안에서 이동' : '지형 따라 이동 · 구조물 충돌 미적용'}`;
};
const resize = () => {
profile?.transition();
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
overviewCamera.aspect = camera.aspect;
overviewCamera.updateProjectionMatrix();
followCamera.aspect = camera.aspect;
followCamera.updateProjectionMatrix();
renderer.setPixelRatio(profile?.forcedDpr ?? Math.min(devicePixelRatio, SCENE.maxPixelRatio));
renderer.setSize(innerWidth, innerHeight);
composer?.setPixelRatio(renderer.getPixelRatio());
composer?.setSize(innerWidth, innerHeight);
rendering.invalidate();
};
window.addEventListener('resize', resize);
resize();
let previousTime = performance.now();
const forward = new Vector3();
const travel = new Vector3();
renderer.setAnimationLoop((now) => {
const cpuStart = profile?.active() ? performance.now() : 0;
const delta = (now - previousTime) / 1000;
previousTime = now;
const replayPosition = profile?.step(now);
let moving = false;
let turning = false;
const destination = controls.destination;
const movementRequested = !networkBlocked && !overview && destination !== null;
if (replayPosition || movementRequested) {
const next = replayPosition ?? walkStep(camera.position, destination!, delta, terrainData.metadata.radiusMeters - 1);
if(summitWalking && !summitWalking.canTraverse(camera.position,next)) {
profile?.rejected();
const alongX=next.clone();alongX.z=camera.position.z;
const alongZ=next.clone();alongZ.x=camera.position.x;
if(summitWalking.canTraverse(camera.position,alongX)) next.copy(alongX);
else if(summitWalking.canTraverse(camera.position,alongZ)) next.copy(alongZ);
else next.copy(camera.position);
}
if ((next.x !== camera.position.x || next.z !== camera.position.z) && (!coast || isCoastalLand(coast.data, next.x, next.z))) {
travel.subVectors(next, camera.position);
if (sheep && !replayPosition) {
const alignment = faceTravel(sheep.group, camera, travel, delta, !controls.looking);
turning = alignment < .999;
next.copy(camera.position).addScaledVector(travel, alignment);
rendering.invalidate();
}
walkedDistance += Math.hypot(next.x-camera.position.x,next.z-camera.position.z);
moving = next.distanceToSquared(camera.position) > 1e-10;
camera.position.copy(next);
placeObserver();
}
if (destination && ((!moving && !turning) || Math.hypot(destination.x-camera.position.x, destination.z-camera.position.z) <= MOVEMENT.arrivalDistance)) controls.stop();
}
playerMoving = moving;
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now())) rendering.invalidate();
ocean?.animate(now / 1000);
let weatherChanged = false;
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
for (const key of Object.keys(atmosphere) as (keyof typeof atmosphere)[]) {
const difference = weatherTarget[key] - atmosphere[key];
if (difference === 0) continue;
atmosphere[key] = Math.abs(difference) < Math.max(1, Math.abs(weatherTarget[key])) * 0.0001
? weatherTarget[key] : atmosphere[key] + difference * blend;
weatherChanged = true;
}
if (weatherChanged) { applyAtmosphere(); rendering.invalidate(); }
const previousEmote = emotes.current;
emotes.update(delta, movementRequested, prayerAvailable());
if (previousEmote !== emotes.current) { refreshEmotes(); rendering.invalidate(); }
if (sheep) {
// Keep facing the sunrise throughout prayer, even if the user moves the mouse.
sheep.group.position.set(camera.position.x, camera.position.y - eyeHeight, camera.position.z);
if (emotes.current === 'pray' && solarState) {
forward.set(...directionFromAngles(solarState.azimuth, 0));
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
} else if (moving && replayPosition) {
faceTravel(sheep.group, camera, travel, delta, !controls.looking);
}
if (sheep.pose(emotes.current, emotes.elapsed, moving, now / 1000)) rendering.invalidate();
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
}
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
if(profile?.active()) rendering.invalidate();
if (!rendering.shouldRender(activeCamera, Boolean(ocean) || precipitating)) { profile?.observe(now, false); return; }
const updateEnd = cpuStart ? performance.now() : 0;
minimap.update(overview ? overviewCamera : camera);
compass.update(activeCamera);
const minimapEnd = cpuStart ? performance.now() : 0;
if (profile) renderer.info.reset();
if (composer && !profile?.disablePost) {
renderPass.camera = activeCamera;
composer.render(delta);
} else renderer.render(scene, activeCamera);
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
submit: performance.now() - minimapEnd });
if (profile?.observe(now, true)) resize();
});
const world = {
buildingCounts: buildings?.counts,
readPlayer(): PlayerUpdate {
const yaw = sheep?.group.rotation.y ?? camera.rotation.y;
return { x: camera.position.x, z: camera.position.z, yaw: Math.atan2(Math.sin(yaw), Math.cos(yaw)),
moving: playerMoving, emote: emotes.current,
equipment: { umbrella: rainGear.umbrella, raincoat: rainGear.raincoat, hood: rainGear.hood } };
},
setConnection(blocked: boolean, clock: (() => number) | null) {
networkBlocked = blocked; sharedNow = clock;
if (blocked) { controls.clear(); playerMoving = false; emotes.cancel(); }
rendering.invalidate();
},
applyPlayer(state: PlayerState) {
controls.clear(); emotes.cancel(); playerMoving = false;
camera.position.x = state.x; camera.position.z = state.z;
if (sheep) sheep.group.rotation.y = state.yaw;
rainGear.apply(state.equipment);
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood);
placeObserver(); refreshEmotes(); rendering.invalidate();
},
synchronizePlayers(players: PlayerState[], selfId: string) {
remotePlayers?.synchronize(players, selfId); rendering.invalidate();
},
clearPlayers() { remotePlayers?.clear(); rendering.invalidate(); },
setWeather(weather: WeatherSample | null) {
weatherTarget = weatherAppearance(weather);
rainGear.updateWeather(weatherTarget.rain, weatherTarget.snowfall);
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood);
refreshGear();
// Do not blend an old precipitation type into the newly selected weather.
if (weatherTarget.rain === 0) atmosphere.rain = 0;
if (weatherTarget.snowfall === 0) atmosphere.snowfall = 0;
rendering.invalidate();
},
resetPosition() {
emotes.cancel();
walkedDistance = 0;
restoreView(); controls.clear(); camera.position.set(start.x, eyeHeight, start.z);
placeObserver();
camera.lookAt(harbour ? target : coast ? new Vector3(target.x, camera.position.y, target.z) : camera.position.clone().add(new Vector3(1,0,0)));
if (!coast) camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(SUMMIT_TERRAIN.heading, SUMMIT_TERRAIN.pitch))));
if (sheep) {
camera.getWorldDirection(forward);
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
}
refreshEmotes();
},
lookAtCity() { restoreView(); camera.lookAt(coast && !harbour ? new Vector3(target.x, camera.position.y, target.z) : target); },
showCityOverview() {
controls.clear();
emotes.cancel();
overview = true;
refreshEmotes();
viewNote.textContent = coast ? '해안 조감 · 실제 눈높이 아님 · 하늘 클릭 시 관측 복귀' : `건물 조감 미리보기 · 기준 지면보다 ${BUILDINGS.overviewHeight}m 위 · 실제 관측 눈높이 아님 · 하늘 클릭 시 관측 복귀`;
},
lookAtStructure(id: string) {
restoreView();
const target = id === SUMMIT_PLATEAU.marker.assetId ? summitMarker?.group : structures?.group.getObjectByName(id);
if (target) camera.lookAt(target.position.clone().add(new Vector3(0, id.includes('mast') ? 14 : id === 'weather-radar' ? 10 : 1, 0)));
},
lookEast() { restoreView(); camera.lookAt(camera.position.clone().add(new Vector3(1, 0, 0))); },
setEyeHeight(meters: number) {
eyeHeight = meters;
placeObserver();
},
update(state: SunState) {
solarState = state;
emotes.update(0, false, prayerAvailable());
refreshEmotes();
// LIVE calls this every second; never skip a new solar/time state.
rendering.invalidate();
const direction = new Vector3(...directionFromAngles(state.azimuth, state.altitude));
applyAtmosphere();
ocean?.update(direction, state.altitude);
buildings?.update(state.altitude);
if (bloom) {
bloom.enabled = state.altitude < BUILDINGS.lighting.fadeEndAltitude;
bloom.strength = BUILDINGS.lighting.bloomStrength * (1 - MathUtils.smoothstep(state.altitude,
BUILDINGS.lighting.fadeStartAltitude, BUILDINGS.lighting.fadeEndAltitude));
}
horizon.update(state.altitude);
fog.color.copy(nightFog).lerp(dawnFog, MathUtils.smoothstep(state.altitude, -8, 0));
fog.color.lerp(dayFog, MathUtils.smoothstep(state.altitude, 0, 15));
renderer.toneMappingExposure = MathUtils.lerp(0.12, 0.65, MathUtils.smoothstep(state.altitude, -12, 5));
},
dispose() {
disposed = true;
profile?.dispose();
renderer.setAnimationLoop(null);
minimap.dispose();
compass.dispose();
bloom?.dispose();
outputPass?.dispose();
renderPass.dispose();
composer?.dispose();
controls.dispose();
emoteEvents.abort();
remotePlayers?.clear();
sheep?.dispose();
precipitation.dispose();
nightSky.dispose();
terrain.dispose();
summitRelief?.dispose();
summitMarker?.dispose();
summitTrails?.dispose();
structures?.dispose();
buildings?.dispose();
ocean?.dispose();
harbour?.dispose();
canvas.removeEventListener('pointerdown', restoreView);
window.removeEventListener('resize', resize);
renderer.dispose();
},
};
if (import.meta.env.DEV && profiling) void import('../dev/renderProfile.ts').then(({ createRenderProfile }) => {
if (disposed) return;
profile = createRenderProfile(renderer, scene, camera, () => world.resetPosition(), target, () =>
composer && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
renderer.info.autoReset = false;
resize();
});
return world;
}