A measured LOD chain for Roblox
Save a mesh into Characters/ and push. A moment later the project has its LOD chain beside it: hero.lod0.obj under Roblox's cap of 20,000 triangles, then hero.lod1.obj and on, each about half the one before. Every level is measured, not just cut: it is rendered from sixteen angles beside the original and compared with SSIM, and a level that looks too far from the original is cut more gently and tried again. The worst level's score goes into the save's message.
Everything in it is free: meshoptimizer (MIT) does the cutting, and the measuring is forty lines of plain JavaScript.
1. A project
Any folder becomes a project (here a new one). Publish it so its actions can run:
mkdir my-game
ifile init my-game
ifile link my-game
2. The action: two files
An action is two files in the project's .interlinked/actions folder: the definition (when it runs, where, what it may touch, what it runs) and the script it runs. Create these three files in my-game.
.interlinked/actions/meshes.yml
# A mesh saved into Characters/ comes back as a measured LOD chain.
#
# runs-on: maker runs it on the machine that pushed the save, once that machine is switched on for the
# project (`ifile runner start`). Nothing is rented and no hosted minutes are used. Change it to `team` to
# let any machine your team switched on take it, or to a machine's name for one machine.
name: Roblox-ready meshes
on:
push:
paths: [Characters/**/*.obj]
kinds: [added, changed]
runs-on: maker
needs:
read: [Characters/**]
write: [Characters/**/*.lod*.obj]
tools: [node, npm]
steps:
- name: The mesh library
run: npm install --no-audit --no-fund --no-save [email protected]
- name: Cut the chain
run: node .interlinked/actions/lod-chain.mjs
each: changed
save:
message: "LOD chain for {count} meshes, worst score {score}"
each: changed runs the step once for every mesh the save changed, its path in FILE. {count} is how many of the save's files set it off; {score} is what the script wrote to IFILE_OUTPUT.
.interlinked/actions/lod-chain.mjs
// The LOD chain of one mesh (the action's step runs this once for each changed mesh, its path in FILE).
//
// In: a Wavefront .obj.
// Out: <name>.lod0.obj, the top level, at most Roblox's cap of 20,000 triangles; then <name>.lod1.obj and
// on, each about half the one before, down to a few hundred. meshoptimizer does the cutting; each level
// is then MEASURED (measure.mjs): rendered from sixteen angles beside the original and compared with
// SSIM. A level that looks too far from the original is cut more gently, and tried again, until it
// passes the visual budget or would be no smaller than the level before (where the chain ends).
// The SCORE is the worst level's difference from the original (1 minus its SSIM, so 0 is identical):
// it goes into the save's message through IFILE_OUTPUT.
//
// It reads FILE and IFILE_OUTPUT from the environment, so the same line works in any shell.
import { readFileSync, writeFileSync, existsSync, readdirSync, rmSync } from "node:fs"
import path from "node:path"
import { MeshoptSimplifier } from "meshoptimizer"
import { viewsOf, similarity } from "./measure.mjs"
const CAP = 20_000 // Roblox's triangle cap for a mesh
const FLOOR = 300 // a level below this many triangles is not worth keeping
const BUDGET = 0.05 // the most meshoptimizer may move a surface, as a fraction of the mesh's size
const LOOKS = 0.80 // the least SSIM a level may have against the original, at its worst angle
const file = process.env.FILE
if (!file) { console.error("FILE is not set: this script is a step with `each: changed`"); process.exit(2) }
if (/\.lod\d+\.obj$/i.test(file)) { console.log(`${file}: a level of a chain, not a mesh to cut`); process.exit(0) }
// The mesh: positions, and triangles (a face of more than three corners is fanned).
const positions = [], indices = []
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
if (line.startsWith("v ")) { const [, x, y, z] = line.trim().split(/\s+/); positions.push(Number(x), Number(y), Number(z)) }
else if (line.startsWith("f ")) {
const c = line.trim().split(/\s+/).slice(1).map((t) => { const i = parseInt(t.split("/")[0], 10); return i < 0 ? positions.length / 3 + i : i - 1 })
for (let i = 1; i + 1 < c.length; i++) indices.push(c[0], c[i], c[i + 1])
}
}
const triangles = indices.length / 3
if (!triangles) { console.error(`${file}: no faces in it`); process.exit(1) }
await MeshoptSimplifier.ready
const verts = new Float32Array(positions), all = new Uint32Array(indices)
const stem = file.replace(/\.obj$/i, "")
// Levels of an earlier chain of this mesh go: this run's chain may be shorter.
for (const f of readdirSync(path.dirname(file) || ".")) if (f.startsWith(`${path.basename(stem)}.lod`) && /\.lod\d+\.obj$/i.test(f)) rmSync(path.join(path.dirname(file) || ".", f))
const write = (level, idx) => {
// Only the vertices the level uses, renumbered.
const map = new Map(), out = []
for (const i of idx) if (!map.has(i)) { map.set(i, map.size + 1); out.push(`v ${verts[i * 3]} ${verts[i * 3 + 1]} ${verts[i * 3 + 2]}`) }
for (let i = 0; i < idx.length; i += 3) out.push(`f ${map.get(idx[i])} ${map.get(idx[i + 1])} ${map.get(idx[i + 2])}`)
writeFileSync(`${stem}.lod${level}.obj`, out.join("\n") + "\n")
}
const views = viewsOf(verts, all)
let worst = 0, level = 0
const chain = []
for (let target = Math.min(triangles, CAP); target >= FLOOR; target = Math.floor(target / 2)) {
// Cut, measure, and while it looks too far from the original cut more gently (a quarter more triangles
// each try), never past the level before.
let tries = 0, idx, looks
for (let want = target; ; want = Math.ceil(want * 1.25)) {
idx = want >= triangles ? all : MeshoptSimplifier.simplify(all, verts, 3, want * 3, BUDGET, ["LockBorder"])[0]
looks = idx === all ? 1 : similarity(views, verts, idx)
tries++
if (looks >= LOOKS || (chain.length && idx.length / 3 >= chain.at(-1).triangles) || want >= triangles) break
}
const got = idx.length / 3
// A level that could not pass, or could not be brought under the last one, ends the chain.
if (chain.length && (got >= chain.at(-1).triangles || looks < LOOKS)) break
write(level, idx)
chain.push({ level, triangles: got, looks, tries })
worst = Math.max(worst, 1 - looks)
level++
}
console.log(`${file}: ${triangles} triangles -> ${chain.map((c) => `lod${c.level} ${c.triangles} (looks ${c.looks.toFixed(3)}${c.tries > 1 ? `, ${c.tries} cuts` : ""})`).join(", ")}`)
// The score for the save's message: the worst of every mesh this run cut.
const out = process.env.IFILE_OUTPUT
if (out) {
const before = existsSync(out) ? Number((readFileSync(out, "utf8").match(/^score=(.*)$/m) ?? [])[1] ?? 0) : 0
writeFileSync(out, `score=${Math.max(before, worst).toFixed(4)}\n`)
}
.interlinked/actions/measure.mjs
// HOW MUCH A LEVEL LOOKS LIKE THE ORIGINAL: render both from a ring of angles and compare the pictures with
// SSIM (structural similarity, the standard image metric: 1 is identical). Free, no GPU, no library: a small
// software renderer (flat shading, a depth buffer, an orthographic camera) and the SSIM formula.
//
// const views = viewsOf(positions, indices) the original's pictures, once
// const s = similarity(views, positions, levelIdx) the worst SSIM of a level over every angle
//
// The camera frames the ORIGINAL's bounding sphere for every level, so a level that shrinks or moves is
// seen to. Eight angles around the mesh at two heights; 128 pixels square.
const SIZE = 128
const ANGLES = [0, 45, 90, 135, 180, 225, 270, 315].flatMap((y) => [[y, 15], [y, -35]])
function frame(pos) {
let min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
for (let i = 0; i < pos.length; i += 3) for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], pos[i + k]); max[k] = Math.max(max[k], pos[i + k]) }
const c = [0, 1, 2].map((k) => (min[k] + max[k]) / 2)
let r = 0
for (let i = 0; i < pos.length; i += 3) r = Math.max(r, Math.hypot(pos[i] - c[0], pos[i + 1] - c[1], pos[i + 2] - c[2]))
return { c, r: r || 1 }
}
/** One picture: the mesh seen from (yaw, pitch) degrees, each face lit by how squarely it faces the light. */
function render(pos, idx, fr, yaw, pitch) {
const a = (yaw * Math.PI) / 180, b = (pitch * Math.PI) / 180
const ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b)
const n = pos.length / 3
const sx = new Float32Array(n), sy = new Float32Array(n), sz = new Float32Array(n)
for (let i = 0; i < n; i++) {
const x = (pos[i * 3] - fr.c[0]) / fr.r, y = (pos[i * 3 + 1] - fr.c[1]) / fr.r, z = (pos[i * 3 + 2] - fr.c[2]) / fr.r
const x1 = ca * x + sa * z, z1 = -sa * x + ca * z
const y2 = cb * y - sb * z1, z2 = sb * y + cb * z1
sx[i] = (x1 * 0.48 + 0.5) * SIZE; sy[i] = (0.5 - y2 * 0.48) * SIZE; sz[i] = z2
}
const img = new Float32Array(SIZE * SIZE), depth = new Float32Array(SIZE * SIZE).fill(-Infinity)
for (let t = 0; t < idx.length; t += 3) {
const i0 = idx[t], i1 = idx[t + 1], i2 = idx[t + 2]
const ax = sx[i0], ay = sy[i0], bx = sx[i1], by = sy[i1], cx = sx[i2], cy = sy[i2]
const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
if (Math.abs(area) < 1e-9) continue
// The face's light, from its normal in view space (both sides lit: meshes are not always closed).
const ux = bx - ax, uy = -(by - ay), uz = sz[i1] - sz[i0], vx = cx - ax, vy = -(cy - ay), vz = sz[i2] - sz[i0]
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
const shade = 0.15 + 0.85 * Math.abs((nx * 0.3 + ny * 0.5 + nz * 0.81) / (Math.hypot(nx, ny, nz) || 1))
const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx))), x1 = Math.min(SIZE - 1, Math.ceil(Math.max(ax, bx, cx)))
const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy))), y1 = Math.min(SIZE - 1, Math.ceil(Math.max(ay, by, cy)))
for (let py = y0; py <= y1; py++) for (let px = x0; px <= x1; px++) {
const qx = px + 0.5, qy = py + 0.5
const w0 = ((bx - qx) * (cy - qy) - (by - qy) * (cx - qx)) / area
const w1 = ((cx - qx) * (ay - qy) - (cy - qy) * (ax - qx)) / area
const w2 = 1 - w0 - w1
if (w0 < 0 || w1 < 0 || w2 < 0) continue
const z = w0 * sz[i0] + w1 * sz[i1] + w2 * sz[i2], k = py * SIZE + px
if (z > depth[k]) { depth[k] = z; img[k] = shade }
}
}
return img
}
/** SSIM of two pictures, the mean over 8 by 8 windows (values 0 to 1). */
function ssim(p, q) {
const C1 = 0.01 ** 2, C2 = 0.03 ** 2, W = 8
let sum = 0, count = 0
for (let y = 0; y + W <= SIZE; y += 4) for (let x = 0; x + W <= SIZE; x += 4) {
let mp = 0, mq = 0
for (let j = 0; j < W; j++) for (let i = 0; i < W; i++) { const k = (y + j) * SIZE + x + i; mp += p[k]; mq += q[k] }
mp /= W * W; mq /= W * W
let vp = 0, vq = 0, cv = 0
for (let j = 0; j < W; j++) for (let i = 0; i < W; i++) { const k = (y + j) * SIZE + x + i; const dp = p[k] - mp, dq = q[k] - mq; vp += dp * dp; vq += dq * dq; cv += dp * dq }
vp /= W * W - 1; vq /= W * W - 1; cv /= W * W - 1
sum += ((2 * mp * mq + C1) * (2 * cv + C2)) / ((mp * mp + mq * mq + C1) * (vp + vq + C2))
count++
}
return sum / count
}
/** The original's pictures and its frame, made once per mesh. */
export function viewsOf(pos, idx) {
const fr = frame(pos)
return { fr, pictures: ANGLES.map(([y, p]) => render(pos, idx, fr, y, p)) }
}
/** The worst SSIM of a level against the original, over every angle. */
export function similarity(views, pos, idx) {
let worst = 1
ANGLES.forEach(([y, p], i) => { worst = Math.min(worst, ssim(views.pictures[i], render(pos, idx, views.fr, y, p))) })
return worst
}
3. Where it runs
runs-on: maker runs it on the machine that pushed the save, so nothing is rented and the files are already there. A machine runs nothing until its owner switches it on for the project; leave this running in a second terminal (or press Run actions here on the app's Flow tab):
ifile runner start --in my-game
Rather nobody's machine at all? Change the line to runs-on: hosted and it runs on a machine we start for the run, which carries Node. That is the only place with a price: Cloudflare's container rates plus 25%, about half a cent a minute.
4. A mesh
Save any .obj into my-game/Characters/ (a folder of its own: everything the action reads and writes is inside it), then save and push:
ifile save my-game -m "the hero mesh"
ifile push my-game
5. What came back
ifile runs --in my-game
ifile sync my-game
ifile runs log 1 --in my-game
ifile runs shows the run: on which machine, how long, and the save it made. After ifile sync the levels are in Characters/, and the run's log prints the chain:
Characters/hero.obj: 40000 triangles -> lod0 20000 (looks 0.989), lod1 10000 (looks 0.980), lod2 5000 (looks 0.964), lod3 2500 (looks 0.944), lod4 1250 (looks 0.902), lod5 712 (looks 0.833)
"Looks" is the level's SSIM against the original at its worst angle (1 is identical). The save's message carries the chain's score, 1 minus the worst level's SSIM. The action's own save sets off no action, so it never loops; save the mesh again and the chain is cut again from the new one.
Make it yours
- The budget.
LOOKSinlod-chain.mjsis the least SSIM a level may have (0.80); raise it for a chain that stays closer to the original, at more triangles per level. - Another engine's cap.
CAPis Roblox's 20,000; set it to your engine's. - Other folders. Change
paths,readandwritetogether: a run can read and write only what its file names.