feat: initialize sunrise with 100-player shared rooms
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(); } };
|
||||
}
|
||||
@@ -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(); });
|
||||
} };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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; } };
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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()); } };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
} };
|
||||
}
|
||||
@@ -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();
|
||||
}};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(); } };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user