Files
2026-10-06 22:25:34 -04:00

277 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Document,
NodeIO,
} from "@gltf-transform/core";
import {
ALL_EXTENSIONS,
KHRMaterialsVariants,
KHRTextureTransform,
} from "@gltf-transform/extensions";
const REPEAT = 10497;
const CLAMP_TO_EDGE = 33071;
function addAccessor(document, buffer, name, type, array) {
return document.createAccessor(name, buffer).setType(type).setArray(array);
}
function createTerrainMaterial(document, textureTransformExtension, name, texture, tileScale, extras = null) {
const material = document.createMaterial(name)
.setBaseColorTexture(texture)
.setBaseColorFactor([1, 1, 1, 1])
.setMetallicFactor(0)
.setRoughnessFactor(1)
.setAlphaMode("OPAQUE");
const textureInfo = material.getBaseColorTextureInfo();
textureInfo.setWrapS(REPEAT).setWrapT(REPEAT);
textureInfo.setExtension(KHRTextureTransform.EXTENSION_NAME, textureTransformExtension.createTransform()
.setScale([Number(tileScale), -Number(tileScale)]));
if (extras) material.setExtras(extras);
return material;
}
function addVariantMapping(document, variantExtension, mappingList, material, name) {
const variant = variantExtension.createVariant(name);
mappingList.addMapping(variantExtension.createMapping().setMaterial(material).addVariant(variant));
return variant;
}
function setTextureProvenance(texture, sourceDDS, extra = {}) {
texture.setExtras({
...texture.getExtras(),
runewakerSourceDDS: sourceDDS,
...extra,
});
}
function createVisualDocument(converted, textureSources, provenance) {
const slug = provenance.dungeonId ?? "sand-vortex";
const title = provenance.dungeonTitle ?? "Sand Vortex";
const document = new Document();
const root = document.getRoot();
const buffer = document.createBuffer(`${slug}-native-terrain-buffer`);
const scene = document.createScene(`${title} native terrain`);
const partitionChunks = provenance.partitionVisualChunks === true;
const mesh = partitionChunks ? null : document.createMesh(`${title} terrain tiles`);
const chunkNodes = [];
const variants = document.createExtension(KHRMaterialsVariants).setRequired(false);
const textureTransform = document.createExtension(KHRTextureTransform).setRequired(false);
const textureBySource = new Map();
for (const [sourceKey, details] of textureSources.textureBySource) {
const texture = document.createTexture(details.imageName)
.setImage(details.image)
.setMimeType("image/png")
.setExtras({
runewakerSourceDDS: details.sourceDDS,
...(details.sourceCompressedFile ? { runewakerSourceCompressed: details.sourceCompressedFile } : {}),
...(details.channelLayerIds ? { runewakerWeightChannels: details.channelLayerIds } : {}),
runewakerColorPolicy: details.alphaPolicy ?? details.colorSpace,
});
textureBySource.set(sourceKey, texture);
}
const positions = new Float32Array(converted.vertexCount * 3);
const normals = new Float32Array(converted.vertexCount * 3);
const uvs = new Float32Array(converted.vertexCount * 2);
for (const chunk of converted.chunks) {
positions.set(chunk.positions, chunk.vertexOffset * 3);
normals.set(chunk.normals, chunk.vertexOffset * 3);
uvs.set(chunk.uvs, chunk.vertexOffset * 2);
}
const positionAccessor = partitionChunks ? null : addAccessor(document, buffer, `${slug}-native-terrain-positions`, "VEC3", positions);
const normalAccessor = partitionChunks ? null : addAccessor(document, buffer, `${slug}-native-terrain-normals`, "VEC3", normals);
const uvAccessor = partitionChunks ? null : addAccessor(document, buffer, `${slug}-native-terrain-normalized-local-xz`, "VEC2", uvs);
for (const chunk of converted.chunks) {
const layers = chunk.layers;
const weights = chunk.splatMaps;
if (!Array.isArray(layers) || layers.length < 1 || layers.length > 6) {
throw new Error(`${chunk.id} must contain 1–6 source terrain layers.`);
}
if (!Array.isArray(weights) || weights.length !== Math.ceil(layers.length / 3)) {
throw new Error(`${chunk.id} weight-map count does not match its terrain-layer count.`);
}
const metadata = {
version: 1,
layerVariants: chunk.material.variantNames,
weightVariants: chunk.material.weightVariantNames,
tileScales: layers.map((layer) => Number(layer.tileScale)),
...(provenance.cliffProjection ? { cliffProjection: provenance.cliffProjection } : {}),
};
if (metadata.layerVariants.length !== layers.length || metadata.weightVariants.length !== weights.length) {
throw new Error(`${chunk.id} variant name counts do not match its source terrain data.`);
}
const firstLayer = layers[0];
const firstTexture = textureBySource.get(firstLayer.sourceDDS);
if (!firstTexture) throw new Error(`${chunk.id} default tile image is missing: ${firstLayer.sourceDDS}`);
const defaultMaterial = createTerrainMaterial(
document,
textureTransform,
`${chunk.id} default layer ${firstLayer.layerId}`,
firstTexture,
firstLayer.tileScale,
{ runewakerTerrain: metadata },
);
const mappingList = variants.createMappingList();
addVariantMapping(document, variants, mappingList, defaultMaterial, metadata.layerVariants[0]);
for (let layerIndex = 1; layerIndex < layers.length; layerIndex += 1) {
const layer = layers[layerIndex];
const texture = textureBySource.get(layer.sourceDDS);
if (!texture) throw new Error(`${chunk.id} layer image is missing: ${layer.sourceDDS}`);
const material = createTerrainMaterial(
document,
textureTransform,
`${chunk.id} layer ${layer.layerId}`,
texture,
layer.tileScale,
);
addVariantMapping(document, variants, mappingList, material, metadata.layerVariants[layerIndex]);
}
for (let weightIndex = 0; weightIndex < weights.length; weightIndex += 1) {
const weight = weights[weightIndex];
const texture = textureBySource.get(weight.linearPngFile);
if (!texture) throw new Error(`${chunk.id} linear splat image is missing: ${weight.linearPngFile}`);
const material = document.createMaterial(`${chunk.id} linear weights ${weight.mapIndex}`)
.setMetallicFactor(0)
.setRoughnessFactor(1)
.setAlphaMode("OPAQUE")
.setMetallicRoughnessTexture(texture)
.setOcclusionTexture(texture)
.setExtras({
runewakerTerrainWeightMap: {
mapIndex: weight.mapIndex,
channelLayerIds: weight.channelLayerIds,
sourceDDS: weight.splatDDS,
linearPngFile: weight.linearPngFile,
channels: "linear RGB; source alpha ignored",
},
});
material.getMetallicRoughnessTextureInfo().setWrapS(CLAMP_TO_EDGE).setWrapT(CLAMP_TO_EDGE);
material.getOcclusionTextureInfo().setWrapS(CLAMP_TO_EDGE).setWrapT(CLAMP_TO_EDGE);
addVariantMapping(document, variants, mappingList, material, metadata.weightVariants[weightIndex]);
}
const indices = addAccessor(
document,
buffer,
`${chunk.id}-source-triangle-indices-with-mask-slots`,
"SCALAR",
partitionChunks ? Uint16Array.from(chunk.visualIndices, index => index - chunk.vertexOffset) : chunk.visualIndices,
);
const primitive = document.createPrimitive()
.setAttribute("POSITION", positionAccessor ?? addAccessor(document, buffer, `${chunk.id}-positions`, "VEC3", chunk.positions))
.setAttribute("NORMAL", normalAccessor ?? addAccessor(document, buffer, `${chunk.id}-normals`, "VEC3", chunk.normals))
.setAttribute("TEXCOORD_0", uvAccessor ?? addAccessor(document, buffer, `${chunk.id}-uvs`, "VEC2", chunk.uvs))
.setIndices(indices)
.setMaterial(defaultMaterial)
.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mappingList)
.setExtras({
runewakerTerrainChunk: {
id: chunk.id,
coordinate: chunk.coordinate,
sourceVertexFlagsSha256: chunk.vertexFlagsSha256 ?? null,
sourceCellVisibilityFlagsSha256: chunk.cellVisibilityFlagsSha256 ?? null,
sourceTriangleVisibilityFlags: Buffer.from(chunk.triangleVisibilityFlags).toString("base64"),
sourceTriangleVisibilityFlagsEncoding: "base64 encoded Uint8Array; one value per source triangle; 1 visible / 0 masked",
sourceTriangleVisibilitySha256: chunk.triangleVisibilitySha256 ?? null,
sourceTriangleSlots: chunk.triangleVisibilityFlags.length,
visibleSourceTriangles: chunk.visibleSourceTriangles,
maskedSourceTriangles: chunk.maskedSourceTriangles,
},
});
if (mesh) mesh.addPrimitive(primitive);
else chunkNodes.push(document.createNode(chunk.id)
.setMesh(document.createMesh(`${chunk.id} native tile`).addPrimitive(primitive)));
}
const node = document.createNode(`${title} native terrain`)
.setExtras({
runewakerTerrain: {
version: 1,
source: "native WDB terrain chunks; no resampling or layer baking",
chunkCount: converted.chunks.length,
partition: partitionChunks ? "native-chunk-meshes" : "shared-grid-mesh",
sourceManifestSha256: provenance.sourceManifestSha256,
primaryPlacementGuid: provenance.primaryPlacementGuid,
},
});
if (mesh) node.setMesh(mesh);
for (const chunkNode of chunkNodes) node.addChild(chunkNode);
scene.addChild(node);
root.setExtras({
runewakerTerrain: {
version: 1,
source: "Runewaker native WDB heightfield and texture list",
sourceManifestSha256: provenance.sourceManifestSha256,
sourceWdbSha256: provenance.sourceWdbSha256,
primaryPlacementGuid: provenance.primaryPlacementGuid,
metersPerSourceUnit: provenance.metersPerSourceUnit,
coordinateBridge: "inverse primary descriptor TRS, then (x,y,z)=(x,y,-z)*metersPerSourceUnit",
vertexNormalSource: "decoded native vertexData RGB; native terrain shader normal",
textureSourceDDs: provenance.textureSourceDDS,
},
});
root.setDefaultScene(scene);
return document;
}
function createCollisionDocument(converted, provenance) {
const slug = provenance.dungeonId ?? "sand-vortex";
const title = provenance.dungeonTitle ?? "Sand Vortex";
const document = new Document();
const root = document.getRoot();
const buffer = document.createBuffer(`${slug}-native-terrain-collision-buffer`);
const scene = document.createScene(`${title} native terrain collision`);
const mesh = document.createMesh(`${title} native terrain collision surfaces`);
const partitionChunks = provenance.partitionVisualChunks === true;
const positions = new Float32Array(partitionChunks ? 0 : converted.vertexCount * 3);
if (!partitionChunks) for (const chunk of converted.chunks) positions.set(chunk.positions, chunk.vertexOffset * 3);
const positionAccessor = partitionChunks ? null : addAccessor(document, buffer, `${slug}-native-terrain-collision-positions`, "VEC3", positions);
for (const chunk of converted.chunks) {
const indices = addAccessor(
document,
buffer,
`${chunk.id}-visible-collision-triangles`,
"SCALAR",
partitionChunks ? Uint16Array.from(chunk.collisionIndices, index => index - chunk.vertexOffset) : chunk.collisionIndices,
);
mesh.addPrimitive(document.createPrimitive()
.setAttribute("POSITION", positionAccessor ?? addAccessor(document, buffer, `${chunk.id}-collision-positions`, "VEC3", chunk.positions))
.setIndices(indices)
.setExtras({
runewakerTerrainCollisionChunk: {
id: chunk.id,
coordinate: chunk.coordinate,
sourceTriangleSlots: chunk.triangleVisibilityFlags.length,
sourceVisibleTriangles: chunk.visibleSourceTriangles,
includedCollisionTriangles: chunk.collisionIndices.length / 3,
maskedTrianglesExcluded: chunk.maskedSourceTriangles,
},
}));
}
scene.addChild(document.createNode(`${title} native terrain collision`).setMesh(mesh));
root.setExtras({
runewakerTerrainCollision: {
version: 1,
sourceManifestSha256: provenance.sourceManifestSha256,
sourceTriangleSlots: converted.report.sourceTriangleSlots,
includedVisibleTriangles: converted.report.collisionTriangles,
maskedOrDegenerateTrianglesExcluded: converted.report.excludedCollisionTriangles,
},
});
root.setDefaultScene(scene);
return document;
}
export async function writeNativeTerrainGlbs({ converted, textureSources, provenance, visualFile, collisionFile }) {
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
await io.write(visualFile, createVisualDocument(converted, textureSources, provenance));
await io.write(collisionFile, createCollisionDocument(converted, provenance));
}