forked from phenom/healer-man
277 lines
13 KiB
JavaScript
277 lines
13 KiB
JavaScript
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));
|
||
}
|