build-streaming-tiles.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. // Convert existing merged cells into small spatial GLBs. Source assets remain untouched.
  2. // node tools/city-massing/build-streaming-tiles.mjs <batch-dir> [--max-triangles 20000]
  3. import assert from 'node:assert/strict'
  4. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
  5. import { join, resolve } from 'node:path'
  6. import { createHash } from 'node:crypto'
  7. import draco3d from 'draco3d'
  8. const args = process.argv.slice(2)
  9. const option = (key, fallback) => { const index = args.indexOf(key); return index < 0 ? fallback : Number(args.splice(index, 2)[1]) }
  10. const maxTriangles = option('--max-triangles', 20000)
  11. const coarseDistance = option('--coarse-distance', 1500)
  12. const detailDistance = option('--detail-distance', 250)
  13. const generationIndex = args.indexOf('--generation')
  14. const generation = generationIndex < 0 ? 'streaming-v2' : args.splice(generationIndex, 2)[1]
  15. assert.match(generation, /^streaming-v\d+$/)
  16. assert.ok(Number.isInteger(maxTriangles) && maxTriangles > 0 && maxTriangles <= 20000)
  17. assert.ok(coarseDistance > detailDistance && detailDistance > 0)
  18. const batch = resolve(args[0])
  19. const out = join(batch, generation)
  20. // Never regenerate a directory currently served by the app. A new generation gets a new URL.
  21. assert.ok(!existsSync(join(out, 'manifest.json')), `output already exists: ${out}`)
  22. mkdirSync(out, { recursive: true })
  23. const manifest = JSON.parse(readFileSync(join(batch, 'manifest.json'), 'utf8'))
  24. const originalTree = JSON.parse(readFileSync(join(batch, 'tileset.json'), 'utf8'))
  25. const draco = await draco3d.createDecoderModule({})
  26. const encoderModule = await draco3d.createEncoderModule({})
  27. const report = { generation, sourceBatch: batch, count: manifest.count, encoding: 'draco-small-tiles',
  28. provenance: manifest.provenance || { gisFootprints: false, roadsProcedural: null, facadeDetails: null },
  29. budget: { maxTriangles, maxVertices: maxTriangles * 3, coarseDistance, detailDistance },
  30. origin: manifest.origin, cells: [], files: [], totals: { lod1: { triangles: 0, bytes: 0 }, lod0: { triangles: 0, bytes: 0 } } }
  31. function parseGlb(path) {
  32. const bytes = readFileSync(path)
  33. assert.equal(bytes.toString('ascii', 0, 4), 'glTF')
  34. assert.equal(bytes.readUInt32LE(8), bytes.length)
  35. const length = bytes.readUInt32LE(12)
  36. const json = JSON.parse(bytes.toString('utf8', 20, 20 + length))
  37. const offset = length + 20
  38. assert.equal(bytes.toString('ascii', offset + 4, offset + 8), 'BIN\0')
  39. return { bytes, json, bin: bytes.subarray(offset + 8) }
  40. }
  41. function decode(path) {
  42. const { bytes, json, bin } = parseGlb(path)
  43. assert.ok(!json.images?.length && !json.skins?.length, 'this converter is for the untextured CAD building meshes')
  44. const nodes = json.nodes.filter((node) => node.mesh !== undefined)
  45. assert.equal(nodes.length, 1)
  46. assert.ok(!nodes[0].matrix && !nodes[0].translation && !nodes[0].scale && !nodes[0].rotation,
  47. 'source node transforms must already be baked')
  48. const primitives = []
  49. for (const primitive of json.meshes[nodes[0].mesh].primitives) {
  50. assert.equal(primitive.mode ?? 4, 4)
  51. const extension = primitive.extensions.KHR_draco_mesh_compression
  52. const view = json.bufferViews[extension.bufferView]
  53. const encoded = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
  54. const decoder = new draco.Decoder(), buffer = new draco.DecoderBuffer(), mesh = new draco.Mesh()
  55. let status
  56. try {
  57. buffer.Init(new Int8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength), encoded.byteLength)
  58. status = decoder.DecodeBufferToMesh(buffer, mesh)
  59. assert.ok(status.ok(), status.error_msg())
  60. const attributes = {}
  61. for (const [semantic, uniqueId] of Object.entries(extension.attributes)) {
  62. assert.ok(['POSITION', 'NORMAL'].includes(semantic), `unsupported source attribute ${semantic}`)
  63. const attribute = decoder.GetAttributeByUniqueId(mesh, uniqueId)
  64. assert.equal(attribute.num_components(), 3)
  65. const size = mesh.num_points() * 3 * 4
  66. const ptr = draco._malloc(size)
  67. try {
  68. decoder.GetAttributeDataArrayForAllPoints(mesh, attribute, draco.DT_FLOAT32, size, ptr)
  69. attributes[semantic] = new Float32Array(draco.HEAPF32.buffer, ptr, mesh.num_points() * 3).slice()
  70. } finally { draco._free(ptr) }
  71. }
  72. const size = mesh.num_faces() * 3 * 4
  73. const ptr = draco._malloc(size)
  74. let indices
  75. try {
  76. decoder.GetTrianglesUInt32Array(mesh, size, ptr)
  77. indices = new Uint32Array(draco.HEAPU32.buffer, ptr, mesh.num_faces() * 3).slice()
  78. } finally { draco._free(ptr) }
  79. primitives.push({ material: primitive.material, attributes, indices })
  80. } finally {
  81. if (status) draco.destroy(status)
  82. draco.destroy(mesh); draco.destroy(buffer); draco.destroy(decoder)
  83. }
  84. }
  85. const triangleCount = primitives.reduce((sum, primitive) => sum + primitive.indices.length / 3, 0)
  86. const primitiveIndex = new Uint16Array(triangleCount), faceIndex = new Uint32Array(triangleCount)
  87. const centers = new Float32Array(triangleCount * 3)
  88. let current = 0
  89. primitives.forEach((primitive, pi) => {
  90. const positions = primitive.attributes.POSITION
  91. for (let face = 0; face < primitive.indices.length / 3; face++, current++) {
  92. primitiveIndex[current] = pi; faceIndex[current] = face
  93. for (let axis = 0; axis < 3; axis++) {
  94. let value = 0
  95. for (let corner = 0; corner < 3; corner++) value += positions[primitive.indices[face * 3 + corner] * 3 + axis] / 3
  96. centers[current * 3 + axis] = value
  97. }
  98. }
  99. })
  100. return { primitives, materials: json.materials, extensionsUsed: (json.extensionsUsed || []).filter((x) => x !== 'KHR_draco_mesh_compression'),
  101. primitiveIndex, faceIndex, centers, ids: Uint32Array.from({ length: triangleCount }, (_, index) => index), triangleCount,
  102. sourceHash: createHash('sha256').update(bytes).digest('hex') }
  103. }
  104. function subsetBounds(levels, selections) {
  105. const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
  106. for (let level = 0; level < 2; level++) {
  107. const data = levels[level]
  108. for (const id of selections[level]) {
  109. const primitive = data.primitives[data.primitiveIndex[id]], positions = primitive.attributes.POSITION
  110. const face = data.faceIndex[id]
  111. for (let corner = 0; corner < 3; corner++) {
  112. const index = primitive.indices[face * 3 + corner]
  113. // glTF Y-up -> source ENU. The root retains the original ECEF transform.
  114. const enu = [positions[index * 3], -positions[index * 3 + 2], positions[index * 3 + 1]]
  115. for (let axis = 0; axis < 3; axis++) { min[axis] = Math.min(min[axis], enu[axis]); max[axis] = Math.max(max[axis], enu[axis]) }
  116. }
  117. }
  118. }
  119. return { min, max }
  120. }
  121. function partition(levels, selections, leaves) {
  122. if (Math.max(...selections.map((ids) => ids.length)) <= maxTriangles) { leaves.push(selections); return }
  123. const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
  124. levels.forEach((data, level) => {
  125. for (const id of selections[level]) for (let axis = 0; axis < 3; axis++) {
  126. const value = data.centers[id * 3 + axis]
  127. min[axis] = Math.min(min[axis], value); max[axis] = Math.max(max[axis], value)
  128. }
  129. })
  130. const spans = max.map((value, axis) => value - min[axis])
  131. const axis = spans.indexOf(Math.max(...spans)), midpoint = (min[axis] + max[axis]) / 2
  132. const left = [], right = []
  133. levels.forEach((data, level) => {
  134. const ids = selections[level], a = [], b = []
  135. for (const id of ids) (data.centers[id * 3 + axis] < midpoint ? a : b).push(id)
  136. left.push(Uint32Array.from(a)); right.push(Uint32Array.from(b))
  137. })
  138. // Coincident geometry still needs a bounded upload; split deterministically by source index.
  139. if (left.every((ids) => !ids.length) || right.every((ids) => !ids.length)) {
  140. selections.forEach((ids, level) => { left[level] = ids.slice(0, Math.ceil(ids.length / 2)); right[level] = ids.slice(Math.ceil(ids.length / 2)) })
  141. }
  142. partition(levels, left, leaves); partition(levels, right, leaves)
  143. }
  144. function box(bounds) {
  145. const center = bounds.min.map((value, axis) => (value + bounds.max[axis]) / 2)
  146. const half = bounds.min.map((value, axis) => Math.max(0.001, (bounds.max[axis] - value) / 2))
  147. return [center[0], center[1], center[2], half[0], 0, 0, 0, half[1], 0, 0, 0, half[2]]
  148. }
  149. function requestVolume(bounds, distance) {
  150. const center = bounds.min.map((value, axis) => (value + bounds.max[axis]) / 2)
  151. const radius = Math.hypot(...bounds.min.map((value, axis) => (bounds.max[axis] - value) / 2))
  152. return { sphere: [...center, radius + distance] }
  153. }
  154. function exportGlb(name, data, ids, bounds, level) {
  155. const center = bounds.min.map((value, axis) => (value + bounds.max[axis]) / 2)
  156. const gltfCenter = [center[0], center[2], -center[1]]
  157. const chunks = [], views = [], accessors = [], primitives = []
  158. let byteOffset = 0, vertexCount = 0
  159. function appendPayload(bytes) {
  160. const viewIndex = views.length
  161. views.push({ buffer: 0, byteOffset, byteLength: bytes.length })
  162. chunks.push(bytes); byteOffset += bytes.length
  163. if (byteOffset % 4) { const padding = Buffer.alloc(4 - byteOffset % 4); chunks.push(padding); byteOffset += padding.length }
  164. return viewIndex
  165. }
  166. function accessor(type, count, components, range = {}) {
  167. const index = accessors.length
  168. accessors.push({ componentType: type, count, type: components === 1 ? 'SCALAR' : 'VEC3', ...range })
  169. return index
  170. }
  171. const byPrimitive = new Map()
  172. for (const id of ids) {
  173. const pi = data.primitiveIndex[id]
  174. if (!byPrimitive.has(pi)) byPrimitive.set(pi, [])
  175. byPrimitive.get(pi).push(data.faceIndex[id])
  176. }
  177. for (const [pi, faces] of byPrimitive) {
  178. const source = data.primitives[pi], used = new Map(), oldIndices = [], indices = []
  179. for (const face of faces) for (let corner = 0; corner < 3; corner++) {
  180. const index = source.indices[face * 3 + corner]
  181. if (!used.has(index)) { used.set(index, oldIndices.length); oldIndices.push(index) }
  182. indices.push(used.get(index))
  183. }
  184. const builder = new encoderModule.MeshBuilder(), mesh = new encoderModule.Mesh()
  185. const encoder = new encoderModule.Encoder(), encoded = new encoderModule.DracoInt8Array()
  186. try {
  187. builder.AddFacesToMesh(mesh, indices.length / 3, Uint32Array.from(indices))
  188. const attributeIds = {}, ranges = {}
  189. for (const [semantic, values] of Object.entries(source.attributes)) {
  190. const result = new Float32Array(oldIndices.length * 3), min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
  191. oldIndices.forEach((index, output) => {
  192. for (let axis = 0; axis < 3; axis++) {
  193. const value = values[index * 3 + axis] - (semantic === 'POSITION' ? gltfCenter[axis] : 0)
  194. result[output * 3 + axis] = value
  195. min[axis] = Math.min(min[axis], result[output * 3 + axis]); max[axis] = Math.max(max[axis], result[output * 3 + axis])
  196. }
  197. })
  198. attributeIds[semantic] = builder.AddFloatAttributeToMesh(mesh, encoderModule[semantic], oldIndices.length, 3, result)
  199. ranges[semantic] = semantic === 'POSITION' ? { min, max } : {}
  200. }
  201. // Prefer cheap decode over maximum compression. Tiles are tiny, and have local origins.
  202. encoder.SetSpeedOptions(7, 10)
  203. encoder.SetEncodingMethod(encoderModule.MESH_SEQUENTIAL_ENCODING)
  204. encoder.SetAttributeQuantization(encoderModule.POSITION, 16)
  205. encoder.SetAttributeQuantization(encoderModule.NORMAL, 12)
  206. encoder.SetTrackEncodedProperties(true)
  207. const length = encoder.EncodeMeshToDracoBuffer(mesh, encoded)
  208. assert.ok(length > 0, `${name}: Draco encoding failed`)
  209. const points = encoder.GetNumberOfEncodedPoints(), faces = encoder.GetNumberOfEncodedFaces()
  210. assert.equal(faces, indices.length / 3, `${name}: compression changed triangle count`)
  211. vertexCount += points
  212. const bytes = Buffer.alloc(length)
  213. for (let index = 0; index < length; index++) bytes[index] = encoded.GetValue(index) & 255
  214. const attributes = Object.fromEntries(Object.keys(attributeIds).map((semantic) => [semantic, accessor(5126, points, 3, ranges[semantic])]))
  215. primitives.push({ attributes, indices: accessor(points < 65536 ? 5123 : 5125, faces * 3, 1),
  216. material: source.material, mode: 4,
  217. extensions: { KHR_draco_mesh_compression: { bufferView: appendPayload(bytes), attributes: attributeIds } } })
  218. } finally {
  219. encoderModule.destroy(encoded); encoderModule.destroy(encoder); encoderModule.destroy(mesh); encoderModule.destroy(builder)
  220. }
  221. }
  222. assert.ok(vertexCount <= maxTriangles * 3, `${name}: vertex budget`)
  223. const bin = Buffer.concat(chunks)
  224. const json = { asset: { version: '2.0', generator: 'CAD bounded spatial streaming' }, scene: 0,
  225. scenes: [{ nodes: [0] }], nodes: [{ mesh: 0, translation: gltfCenter, name }], meshes: [{ primitives }],
  226. materials: data.materials, accessors, bufferViews: views, buffers: [{ byteLength: bin.length }],
  227. extensionsUsed: [...data.extensionsUsed, 'KHR_draco_mesh_compression'], extensionsRequired: ['KHR_draco_mesh_compression'] }
  228. let jsonBytes = Buffer.from(JSON.stringify(json))
  229. jsonBytes = Buffer.concat([jsonBytes, Buffer.alloc((4 - jsonBytes.length % 4) % 4, 0x20)])
  230. const header = Buffer.alloc(20), binHeader = Buffer.alloc(8)
  231. header.write('glTF'); header.writeUInt32LE(2, 4); header.writeUInt32LE(28 + jsonBytes.length + bin.length, 8)
  232. header.writeUInt32LE(jsonBytes.length, 12); header.write('JSON', 16)
  233. binHeader.writeUInt32LE(bin.length, 0); binHeader.write('BIN\0', 4)
  234. const bytes = Buffer.concat([header, jsonBytes, binHeader, bin])
  235. writeFileSync(join(out, name), bytes)
  236. const record = { uri: name, level, triangles: ids.length, vertices: vertexCount, bytes: bytes.length,
  237. boundsENU: bounds, materials: primitives.length }
  238. report.files.push(record)
  239. report.totals[level].triangles += ids.length; report.totals[level].bytes += bytes.length
  240. return record
  241. }
  242. const rootChildren = []
  243. for (const cell of manifest.cells) {
  244. const levels = [decode(join(batch, cell.lod1.uri)), decode(join(batch, cell.lod0.uri))]
  245. assert.equal(levels[0].triangleCount, cell.lod1.triangles)
  246. assert.equal(levels[1].triangleCount, cell.lod0.triangles)
  247. const leaves = []
  248. partition(levels, levels.map((data) => data.ids), leaves)
  249. const children = []
  250. leaves.forEach((selections, index) => {
  251. const bounds = subsetBounds(levels, selections), volume = { box: box(bounds) }
  252. const records = selections.map((ids, level) => ids.length
  253. ? exportGlb(`${cell.id}-p${String(index).padStart(3, '0')}-lod${1 - level}.glb`, levels[level], ids, bounds, level ? 'lod0' : 'lod1') : null)
  254. const fine = records[1] ? { boundingVolume: volume, geometricError: 0,
  255. viewerRequestVolume: requestVolume(bounds, detailDistance), content: { uri: records[1].uri } } : null
  256. if (records[0]) children.push({ boundingVolume: volume, geometricError: fine ? 16 : 0, refine: 'REPLACE',
  257. viewerRequestVolume: requestVolume(bounds, coarseDistance), content: { uri: records[0].uri }, ...(fine ? { children: [fine] } : {}) })
  258. else if (fine) children.push(fine)
  259. })
  260. // Empty structural parents do not load a huge GLB. Only their small visible children stream.
  261. rootChildren.push({ boundingVolume: { box: box(cell.boundsENU) }, geometricError: 1000000, refine: 'REPLACE', children })
  262. report.cells.push({ id: cell.id, buildingIds: cell.buildingIds, partitions: leaves.length,
  263. source: levels.map((data, index) => ({ level: index ? 'lod0' : 'lod1', triangles: data.triangleCount, sha256: data.sourceHash })) })
  264. console.log(`${cell.id}: ${cell.count} buildings, ${leaves.length} bounded partitions`)
  265. }
  266. const anchor = readFileSync(join(batch, 'root-anchor.glb'))
  267. writeFileSync(join(out, 'root-anchor.glb'), anchor)
  268. const tileset = { asset: { version: '1.1', generator: 'CAD bounded spatial streaming' }, geometricError: 1000000,
  269. root: { transform: originalTree.root.transform, boundingVolume: originalTree.root.boundingVolume, geometricError: 1000000,
  270. refine: 'REPLACE', content: { uri: 'root-anchor.glb' }, children: rootChildren } }
  271. writeFileSync(join(out, 'tileset.json'), JSON.stringify(tileset, null, 2) + '\n')
  272. writeFileSync(join(out, 'manifest.json'), JSON.stringify(report, null, 2) + '\n')
  273. console.log(JSON.stringify({ output: out, buildings: report.count, files: report.files.length,
  274. maxVertices: Math.max(...report.files.map((file) => file.vertices)), totals: report.totals }))