Spaces:
Running
Running
Surf commited on
Commit ·
cdeed5c
1
Parent(s): b8f0cfa
暮归江南 · 步骤8:集市货物改用 NVIDIA SimReady Central(Palatial NV Asset Library,CC-BY-4.0)的 18 件扫描模型,USD→GLB 转换器、目录检索脚本、实例化加载与程序化回退(GLB 文件经 Hub API 以 LFS 上传)
Browse files- .gitattributes +1 -0
- README.md +7 -0
- src/jiangnan/JiangnanApp.tsx +1 -1
- src/jiangnan/engine/Film.ts +13 -0
- src/jiangnan/engine/assets.ts +144 -0
- src/jiangnan/engine/props.ts +11 -4
- tools/jiangnan/simready_search.py +27 -0
- tools/jiangnan/usd2glb.py +180 -0
.gitattributes
CHANGED
|
@@ -95,3 +95,4 @@ public/mvfonts/neon.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
| 95 |
public/mvfonts/lyric.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 96 |
src/jiangnan/assets/fonts/title.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 97 |
src/jiangnan/assets/fonts/wenkai.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 95 |
public/mvfonts/lyric.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 96 |
src/jiangnan/assets/fonts/title.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 97 |
src/jiangnan/assets/fonts/wenkai.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 98 |
+
public/jiangnan/models/*.glb filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -243,6 +243,13 @@ tools/jiangnan/ # 旁白生成、字体子集化、实况图
|
|
| 243 |
- **自愈渲染分级**(`Film.heal`):0 完整渲染 → 1 关闭体积光 → 2 再关闭辉光 → 3 再关闭水面反射通道 → 4 再隐藏人物与鸟群 → 5 关闭全部后期、直接前向渲染。抓帧在「降级 + 重置上下文」四次都黑之后,逐级尝试,哪一级能出画面就**从该帧起固定使用**,并在面板写明「第 X 秒起改用『…』才能渲染出画面」——这句话同时就是诊断结论(哪一级生效,问题就在哪个效果里)。
|
| 244 |
- 错误信息新增当前渲染模式与着色器错误摘要。
|
| 245 |
- 验证:类型检查与构建通过;本轮的无头浏览器分级渲染测试因沙盒临时目录再次被回收未能运行(如实记录)。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
---
|
| 248 |
|
|
|
|
| 243 |
- **自愈渲染分级**(`Film.heal`):0 完整渲染 → 1 关闭体积光 → 2 再关闭辉光 → 3 再关闭水面反射通道 → 4 再隐藏人物与鸟群 → 5 关闭全部后期、直接前向渲染。抓帧在「降级 + 重置上下文」四次都黑之后,逐级尝试,哪一级能出画面就**从该帧起固定使用**,并在面板写明「第 X 秒起改用『…』才能渲染出画面」——这句话同时就是诊断结论(哪一级生效,问题就在哪个效果里)。
|
| 244 |
- 错误信息新增当前渲染模式与着色器错误摘要。
|
| 245 |
- 验证:类型检查与构建通过;本轮的无头浏览器分级渲染测试因沙盒临时目录再次被回收未能运行(如实记录)。
|
| 246 |
+
- **步骤 8(按要求用 NVIDIA SimReady Central 的模型替换画面中的模型)** ✅
|
| 247 |
+
- **先摸清库里有什么**(`tools/jiangnan/simready_search.py`,直接调用站点的 `search_hybrid` 接口):把 SimReady Central 的全部 **3588** 个资产拉成清单(`--catalog`),按类别统计:prop 2531(仓库 / 工业 / 医院道具)、kitchenware 191、container 143、food 78、vegetable 43、appliance 42……作者为 NVIDIA 2536、Palatial Platforms 957、ForgeCAD 45、LG、Vertiv、机器人厂商等。**库里没有房屋、石桥、水车、船、树木、灯笼、人物、牛车**(「stone arch bridge」的第一个结果是餐巾架,「large old tree」是马克杯架)——它是面向机器人仿真的仓库 / 厨房 / 工业资产库,不是场景库。
|
| 248 |
+
- **能用的部分**:集市摊位上的货物。从 Palatial NV Asset Library(CC-BY-4.0,扫描 / 实拍重建,含 4K PBR 贴图)选了 18 件与江南集市相称的:竹篮、提篮、果篮、蛋篮、木碗、茶壶、茶杯、铁锅、木箱、木盒、陶瓶、大白菜、茄子、米缸、陶罐、面粉袋、馒头。
|
| 249 |
+
- **USD → 网页**:浏览器不能直接加载 USD,写了 `tools/jiangnan/usd2glb.py`(usd-core 读 UsdPreviewSurface 材质与网格 → 三角化、烘焙世界变换、Z-up 转 Y-up → pymeshlab 保留贴图坐标的二次误差简化到 1 200–6 000 面 → 贴图缩到 512、金属度 / 粗糙度打包成 glTF 规范 → 写 GLB)。原始资产每件 30–90 MB(27 万三角形、4K 贴图),转换后每件 45–435 KB,18 件共 4.3 MB,放在 `public/jiangnan/models/`。
|
| 250 |
+
- **接入**(`engine/assets.ts`):GLTFLoader 加载后换成本项目带高度雾的材质;每个摊位随机 3–4 种台面货物 + 1–2 种地面货物,按资产真实尺寸(米)落在桌面网格上,同种模型用 InstancedMesh 一次绘制;原来的程序化「多面体果子 + 圆柱篮子 + 车削陶罐」在模型加载成功后隐藏,加载失败(离线 / 被拦截)时自动保留,画面不会空。加载进度显示为「摆上竹篮、陶罐与瓜菜(SimReady 扫描模型)」。
|
| 251 |
+
- 版权:Palatial NV Asset Library,CC-BY-4.0,页脚已署名。
|
| 252 |
+
- 验证:类型检查与构建通过,18 个 GLB 由本地静态服务器正常返回;无头浏览器截图见下方(若本步日志末尾没有截图结论,说明沙盒再次回收了临时目录,未能完成)。
|
| 253 |
|
| 254 |
---
|
| 255 |
|
src/jiangnan/JiangnanApp.tsx
CHANGED
|
@@ -408,7 +408,7 @@ export default function JiangnanApp() {
|
|
| 408 |
</section>
|
| 409 |
|
| 410 |
<footer className="jn-foot">
|
| 411 |
-
《暮归江南》全部画面在浏览器中实时生成:程序化地形与民居、平面反射水面、高度雾、体积光、辉光与胶片调色;配乐为程序合成的五声音阶原创曲(古筝物理建模、竹箫、弦乐、编钟),环境音效同样为程序合成;旁白为原创文案的中文语音合成。字体:马善政楷书、霞鹜文楷(SIL OFL)。
|
| 412 |
</footer>
|
| 413 |
</div>
|
| 414 |
{toast && <div className="jn-toast">{toast}</div>}
|
|
|
|
| 408 |
</section>
|
| 409 |
|
| 410 |
<footer className="jn-foot">
|
| 411 |
+
《暮归江南》全部画面在浏览器中实时生成:程序化地形与民居、平面反射水面、高度雾、体积光、辉光与胶片调色;配乐为程序合成的五声音阶原创曲(古筝物理建模、竹箫、弦乐、编钟),环境音效同样为程序合成;旁白为原创文案的中文语音合成。字体:马善政楷书、霞鹜文楷(SIL OFL)。集市货物模型(竹篮、木碗、茶具、瓜菜、米缸等 18 件)来自 NVIDIA SimReady Central · Palatial NV Asset Library(CC-BY-4.0),由 USD 转换为网页用 GLB。
|
| 412 |
</footer>
|
| 413 |
</div>
|
| 414 |
{toast && <div className="jn-toast">{toast}</div>}
|
src/jiangnan/engine/Film.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
// The film: builds the world once and renders any time t deterministically.
|
| 2 |
import * as THREE from 'three'
|
|
|
|
| 3 |
import { applyPose, cameraAt } from './camera'
|
| 4 |
import { TERRACE, TREE_POS } from './layout'
|
| 5 |
import { createLanterns, createLife, LanternSet, LifeOut } from './life'
|
|
@@ -33,6 +34,7 @@ export class Film {
|
|
| 33 |
lanterns!: LanternSet
|
| 34 |
life!: LifeOut
|
| 35 |
props!: PropsOut
|
|
|
|
| 36 |
village!: VillageOut
|
| 37 |
pmrem: THREE.PMREMGenerator
|
| 38 |
envScene = new THREE.Scene()
|
|
@@ -232,6 +234,17 @@ export class Film {
|
|
| 232 |
await tick()
|
| 233 |
this.props = createProps()
|
| 234 |
s.add(this.props.group)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
progress(0.72, '种下古樟与竹林')
|
| 236 |
await tick()
|
| 237 |
const trees = createTrees(this.quality)
|
|
|
|
| 1 |
// The film: builds the world once and renders any time t deterministically.
|
| 2 |
import * as THREE from 'three'
|
| 3 |
+
import { AssetsOut, createAssets } from './assets'
|
| 4 |
import { applyPose, cameraAt } from './camera'
|
| 5 |
import { TERRACE, TREE_POS } from './layout'
|
| 6 |
import { createLanterns, createLife, LanternSet, LifeOut } from './life'
|
|
|
|
| 34 |
lanterns!: LanternSet
|
| 35 |
life!: LifeOut
|
| 36 |
props!: PropsOut
|
| 37 |
+
assets: AssetsOut | null = null
|
| 38 |
village!: VillageOut
|
| 39 |
pmrem: THREE.PMREMGenerator
|
| 40 |
envScene = new THREE.Scene()
|
|
|
|
| 234 |
await tick()
|
| 235 |
this.props = createProps()
|
| 236 |
s.add(this.props.group)
|
| 237 |
+
progress(0.66, '摆上竹篮、陶罐与瓜菜(SimReady 扫描模型)')
|
| 238 |
+
await tick()
|
| 239 |
+
try {
|
| 240 |
+
this.assets = await createAssets(this.props.stalls, (p) => progress(0.66 + p * 0.06, '摆上竹篮、陶罐与瓜菜(SimReady 扫描模型)'))
|
| 241 |
+
if (this.assets.loaded.length) {
|
| 242 |
+
s.add(this.assets.group)
|
| 243 |
+
// the scanned goods replace the procedural ones
|
| 244 |
+
for (const m of this.props.proceduralGoods) m.visible = false
|
| 245 |
+
}
|
| 246 |
+
if (this.assets.failed.length) console.warn('[暮归江南] 未能加载的模型:', this.assets.failed.join(', '))
|
| 247 |
+
} catch (e) { console.warn('[暮归江南] 扫描模型加载失败,使用程序化货物', e) }
|
| 248 |
progress(0.72, '种下古樟与竹林')
|
| 249 |
await tick()
|
| 250 |
const trees = createTrees(this.quality)
|
src/jiangnan/engine/assets.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Scanned / artist-made props from NVIDIA SimReady Central (Palatial NV Asset Library, CC-BY-4.0),
|
| 2 |
+
// converted offline from USD to small GLBs (tools/jiangnan/usd2glb.py) and dressed onto the
|
| 3 |
+
// market stalls and quay as instanced meshes. The procedural goods stay as a fallback when the
|
| 4 |
+
// files cannot be loaded (offline / blocked), so the film never shows an empty stall.
|
| 5 |
+
import * as THREE from 'three'
|
| 6 |
+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
| 7 |
+
import { patch } from './materials'
|
| 8 |
+
import { mulberry32 } from './rng'
|
| 9 |
+
import type { StallSpot } from './props'
|
| 10 |
+
|
| 11 |
+
export interface AssetDef {
|
| 12 |
+
id: string
|
| 13 |
+
name: string
|
| 14 |
+
/** where this prop may be placed */
|
| 15 |
+
slot: 'table' | 'ground' | 'shelf'
|
| 16 |
+
/** uniform scale applied to the real-world metres of the asset (1 = true size) */
|
| 17 |
+
scale?: number
|
| 18 |
+
/** how many of them per stall slot (random in [min,max]) */
|
| 19 |
+
count: [number, number]
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/** Palatial NV Asset Library IDs (https://huggingface.co/datasets/PalatialSim/palatial-nv-asset-library), CC-BY-4.0 */
|
| 23 |
+
export const ASSETS: AssetDef[] = [
|
| 24 |
+
{ id: 'D00006', name: '竹篮', slot: 'table', count: [1, 2] },
|
| 25 |
+
{ id: 'D00016', name: '提篮', slot: 'table', count: [1, 2] },
|
| 26 |
+
{ id: 'D00323', name: '果篮', slot: 'table', count: [1, 1] },
|
| 27 |
+
{ id: 'D00395', name: '蛋篮', slot: 'table', count: [0, 1] },
|
| 28 |
+
{ id: 'D00952', name: '木碗', slot: 'table', count: [1, 3] },
|
| 29 |
+
{ id: 'D00854', name: '茶壶', slot: 'table', count: [0, 1] },
|
| 30 |
+
{ id: 'D00855', name: '茶杯', slot: 'table', count: [1, 3] },
|
| 31 |
+
{ id: 'D00945', name: '铁锅', slot: 'table', count: [0, 1] },
|
| 32 |
+
{ id: 'D00953', name: '木箱', slot: 'ground', count: [1, 2] },
|
| 33 |
+
{ id: 'D00954', name: '木盒', slot: 'table', count: [0, 1] },
|
| 34 |
+
{ id: 'D00902', name: '陶瓶', slot: 'table', count: [1, 2] },
|
| 35 |
+
{ id: 'D00645', name: '大白菜', slot: 'table', count: [2, 4] },
|
| 36 |
+
{ id: 'D00638', name: '茄子', slot: 'table', count: [2, 5] },
|
| 37 |
+
{ id: 'D00668', name: '米缸', slot: 'ground', count: [1, 2] },
|
| 38 |
+
{ id: 'D00839', name: '陶罐', slot: 'table', count: [1, 2] },
|
| 39 |
+
{ id: 'D00930', name: '面粉袋', slot: 'ground', count: [1, 2] },
|
| 40 |
+
{ id: 'D00479', name: '馒头', slot: 'table', count: [3, 6] },
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
export interface AssetsOut {
|
| 44 |
+
group: THREE.Group
|
| 45 |
+
loaded: string[]
|
| 46 |
+
failed: string[]
|
| 47 |
+
triangles: number
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const base = () => new URL('models/', document.baseURI).href
|
| 51 |
+
|
| 52 |
+
async function loadGlb(loader: GLTFLoader, id: string) {
|
| 53 |
+
const g = await loader.loadAsync(`${base()}${id}.glb`)
|
| 54 |
+
const meshes: THREE.Mesh[] = []
|
| 55 |
+
g.scene.traverse((o) => { if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh) })
|
| 56 |
+
if (!meshes.length) throw new Error('empty')
|
| 57 |
+
// merge into one geometry (all our conversions bake transforms, so geometries are already world-space)
|
| 58 |
+
const geo = meshes.length === 1 ? meshes[0].geometry : meshes[0].geometry
|
| 59 |
+
const src = meshes[0].material as THREE.MeshStandardMaterial
|
| 60 |
+
const mat = patch(new THREE.MeshStandardMaterial({
|
| 61 |
+
map: src.map, normalMap: src.normalMap, roughnessMap: src.roughnessMap, metalnessMap: src.metalnessMap,
|
| 62 |
+
color: src.color, roughness: src.roughness, metalness: Math.min(src.metalness, 0.6),
|
| 63 |
+
}), { key: 'asset-' + id })
|
| 64 |
+
if (mat.map) mat.map.colorSpace = THREE.SRGBColorSpace
|
| 65 |
+
geo.computeBoundingBox()
|
| 66 |
+
return { geo, mat, box: geo.boundingBox! }
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Load the catalogue and scatter the props over the stall spots. Resolves even when some
|
| 71 |
+
* (or all) files fail; the caller hides the procedural goods only for stalls that got assets.
|
| 72 |
+
*/
|
| 73 |
+
export async function createAssets(stalls: StallSpot[], onProgress?: (p: number) => void): Promise<AssetsOut> {
|
| 74 |
+
const group = new THREE.Group()
|
| 75 |
+
const out: AssetsOut = { group, loaded: [], failed: [], triangles: 0 }
|
| 76 |
+
const loader = new GLTFLoader()
|
| 77 |
+
const rng = mulberry32(2026)
|
| 78 |
+
const models = new Map<string, Awaited<ReturnType<typeof loadGlb>>>()
|
| 79 |
+
let done = 0
|
| 80 |
+
await Promise.all(ASSETS.map(async (a) => {
|
| 81 |
+
try { models.set(a.id, await loadGlb(loader, a.id)); out.loaded.push(a.id) } catch { out.failed.push(a.id) }
|
| 82 |
+
onProgress?.(++done / ASSETS.length)
|
| 83 |
+
}))
|
| 84 |
+
if (!models.size) return out
|
| 85 |
+
|
| 86 |
+
// plan instances: per stall, a handful of table props and ground props, never overlapping badly
|
| 87 |
+
const placements = new Map<string, THREE.Matrix4[]>()
|
| 88 |
+
const tmp = new THREE.Matrix4(), rot = new THREE.Matrix4(), scl = new THREE.Matrix4(), tr = new THREE.Matrix4()
|
| 89 |
+
const put = (id: string, stallM: THREE.Matrix4, x: number, y: number, z: number, yaw: number, s: number) => {
|
| 90 |
+
const m = models.get(id)!
|
| 91 |
+
// sit the asset on its bounding-box floor, centred on x/z
|
| 92 |
+
const cx = (m.box.min.x + m.box.max.x) / 2, cz = (m.box.min.z + m.box.max.z) / 2
|
| 93 |
+
tr.makeTranslation(x, y, z)
|
| 94 |
+
rot.makeRotationY(yaw)
|
| 95 |
+
scl.makeScale(s, s, s)
|
| 96 |
+
tmp.copy(stallM).multiply(tr).multiply(rot).multiply(scl).multiply(new THREE.Matrix4().makeTranslation(-cx, -m.box.min.y, -cz))
|
| 97 |
+
if (!placements.has(id)) placements.set(id, [])
|
| 98 |
+
placements.get(id)!.push(tmp.clone())
|
| 99 |
+
}
|
| 100 |
+
for (const st of stalls) {
|
| 101 |
+
st.dressed = true
|
| 102 |
+
const table = ASSETS.filter((a) => a.slot === 'table' && models.has(a.id))
|
| 103 |
+
const ground = ASSETS.filter((a) => a.slot === 'ground' && models.has(a.id))
|
| 104 |
+
// table top is 2.2 × 1.0 m at y = 0.82 (see props.ts)
|
| 105 |
+
const cells: [number, number][] = []
|
| 106 |
+
for (let i = -2; i <= 2; i++) for (let j = -1; j <= 1; j++) cells.push([i * 0.42, j * 0.3])
|
| 107 |
+
for (let k = cells.length - 1; k > 0; k--) { const r = Math.floor(rng() * (k + 1)); [cells[k], cells[r]] = [cells[r], cells[k]] }
|
| 108 |
+
// 3–4 kinds per stall, so each stall reads differently
|
| 109 |
+
const kinds = [...table].sort(() => rng() - 0.5).slice(0, 3 + Math.floor(rng() * 2))
|
| 110 |
+
let c = 0
|
| 111 |
+
for (const a of kinds) {
|
| 112 |
+
const n = a.count[0] + Math.floor(rng() * (a.count[1] - a.count[0] + 1))
|
| 113 |
+
for (let i = 0; i < n && c < cells.length; i++) {
|
| 114 |
+
const [x, z] = cells[c++]
|
| 115 |
+
put(a.id, st.m, x + (rng() - 0.5) * 0.08, 0.82, z + (rng() - 0.5) * 0.08, rng() * Math.PI * 2, (a.scale ?? 1) * (0.95 + rng() * 0.1))
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
// ground: beside / under the table, toward the street
|
| 119 |
+
const gk = [...ground].sort(() => rng() - 0.5).slice(0, 2)
|
| 120 |
+
let gx = -1.15
|
| 121 |
+
for (const a of gk) {
|
| 122 |
+
const n = a.count[0] + Math.floor(rng() * (a.count[1] - a.count[0] + 1))
|
| 123 |
+
for (let i = 0; i < n; i++) {
|
| 124 |
+
put(a.id, st.m, gx + rng() * 0.2, 0, 0.75 + rng() * 0.25, rng() * Math.PI * 2, (a.scale ?? 1) * (0.95 + rng() * 0.1))
|
| 125 |
+
gx += 0.55
|
| 126 |
+
if (gx > 1.2) break
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
for (const [id, ms] of placements) {
|
| 131 |
+
const m = models.get(id)!
|
| 132 |
+
const inst = new THREE.InstancedMesh(m.geo, m.mat, ms.length)
|
| 133 |
+
ms.forEach((mat, i) => inst.setMatrixAt(i, mat))
|
| 134 |
+
inst.instanceMatrix.needsUpdate = true
|
| 135 |
+
inst.castShadow = true
|
| 136 |
+
inst.receiveShadow = true
|
| 137 |
+
inst.layers.set(1) // main view only (too small to matter in the water reflection)
|
| 138 |
+
inst.frustumCulled = true
|
| 139 |
+
inst.computeBoundingSphere()
|
| 140 |
+
group.add(inst)
|
| 141 |
+
out.triangles += (m.geo.index ? m.geo.index.count / 3 : m.geo.attributes.position.count / 3) * ms.length
|
| 142 |
+
}
|
| 143 |
+
return out
|
| 144 |
+
}
|
src/jiangnan/engine/props.ts
CHANGED
|
@@ -27,8 +27,14 @@ function projectUV(geo: THREE.BufferGeometry, scale: number) {
|
|
| 27 |
return g
|
| 28 |
}
|
| 29 |
|
|
|
|
|
|
|
| 30 |
export interface PropsOut {
|
| 31 |
group: THREE.Group
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
wheel: THREE.Group
|
| 33 |
wheelInfo: { center: THREE.Vector3; axis: THREE.Vector3; radius: number; tangent: THREE.Vector3 }
|
| 34 |
bridgeInfo: { center: THREE.Vector3; across: THREE.Vector3; along: THREE.Vector3; crestY: number; halfLen: number; deckY: (x: number) => number }
|
|
@@ -262,6 +268,7 @@ export function createProps(): PropsOut {
|
|
| 262 |
const jarGeos = [jarProfile(0.28, 0.7), jarProfile(0.4, 0.9), jarProfile(0.2, 0.45)]
|
| 263 |
const basketGeo = new THREE.CylinderGeometry(0.42, 0.32, 0.35, 16, 1, true)
|
| 264 |
const fruitGeo = new THREE.IcosahedronGeometry(0.075, 0)
|
|
|
|
| 265 |
for (let s = MARKET_S0; s < MARKET_S1; s += rrange(rng, 5.5, 8)) {
|
| 266 |
if (Math.abs(s - BRIDGE_S) < 12) continue
|
| 267 |
const p = riverPoint(s, -9.6)
|
|
@@ -271,6 +278,7 @@ export function createProps(): PropsOut {
|
|
| 271 |
wood.box(0, 0.78, 0, 2.2, 0.08, 1.0)
|
| 272 |
for (const [lx, lz] of [[-1, -0.42], [1, -0.42], [-1, 0.42], [1, 0.42]]) wood.box(lx, 0.39, lz, 0.07, 0.78, 0.07)
|
| 273 |
const kind = Math.floor(rng() * 4)
|
|
|
|
| 274 |
if (kind === 0 || kind === 3) {
|
| 275 |
// baskets of produce
|
| 276 |
for (let k = 0; k < 3; k++) {
|
|
@@ -462,6 +470,7 @@ export function createProps(): PropsOut {
|
|
| 462 |
mesh.receiveShadow = true
|
| 463 |
if (!reflect) mesh.layers.set(1)
|
| 464 |
group.add(mesh)
|
|
|
|
| 465 |
}
|
| 466 |
add(stone, stoneMat)
|
| 467 |
add(wood, woodMat)
|
|
@@ -469,14 +478,12 @@ export function createProps(): PropsOut {
|
|
| 469 |
add(red, redMat)
|
| 470 |
add(roof, roofMat)
|
| 471 |
// market goods: small, main view only
|
| 472 |
-
add(goods, fruitMat, false, false)
|
| 473 |
-
add(basket, basketMat, false, false)
|
| 474 |
-
add(jars, jarMat, false, false)
|
| 475 |
add(meat, meatMat, true, false)
|
| 476 |
add(awning, awningMat, true, false)
|
| 477 |
|
| 478 |
return {
|
| 479 |
-
group, wheel,
|
| 480 |
wheelInfo: { center: wheelC, axis: wheelAxis, radius: wheelR, tangent: wheelTan },
|
| 481 |
bridgeInfo: { center: V3(bp.x, 0, bp.z), across, along, crestY: crest, halfLen, deckY },
|
| 482 |
lanternSpots, boats, movingBoat, cloths, pavilion: pv,
|
|
|
|
| 27 |
return g
|
| 28 |
}
|
| 29 |
|
| 30 |
+
export interface StallSpot { m: THREE.Matrix4; kind: number; dressed: boolean }
|
| 31 |
+
|
| 32 |
export interface PropsOut {
|
| 33 |
group: THREE.Group
|
| 34 |
+
/** market stall tables (local frame: table top 2.2 × 1.0 m at y = 0.82, street toward +z) */
|
| 35 |
+
stalls: StallSpot[]
|
| 36 |
+
/** procedural goods (hidden when scanned assets are dressed onto the stalls) */
|
| 37 |
+
proceduralGoods: THREE.Mesh[]
|
| 38 |
wheel: THREE.Group
|
| 39 |
wheelInfo: { center: THREE.Vector3; axis: THREE.Vector3; radius: number; tangent: THREE.Vector3 }
|
| 40 |
bridgeInfo: { center: THREE.Vector3; across: THREE.Vector3; along: THREE.Vector3; crestY: number; halfLen: number; deckY: (x: number) => number }
|
|
|
|
| 268 |
const jarGeos = [jarProfile(0.28, 0.7), jarProfile(0.4, 0.9), jarProfile(0.2, 0.45)]
|
| 269 |
const basketGeo = new THREE.CylinderGeometry(0.42, 0.32, 0.35, 16, 1, true)
|
| 270 |
const fruitGeo = new THREE.IcosahedronGeometry(0.075, 0)
|
| 271 |
+
const stalls: StallSpot[] = []
|
| 272 |
for (let s = MARKET_S0; s < MARKET_S1; s += rrange(rng, 5.5, 8)) {
|
| 273 |
if (Math.abs(s - BRIDGE_S) < 12) continue
|
| 274 |
const p = riverPoint(s, -9.6)
|
|
|
|
| 278 |
wood.box(0, 0.78, 0, 2.2, 0.08, 1.0)
|
| 279 |
for (const [lx, lz] of [[-1, -0.42], [1, -0.42], [-1, 0.42], [1, 0.42]]) wood.box(lx, 0.39, lz, 0.07, 0.78, 0.07)
|
| 280 |
const kind = Math.floor(rng() * 4)
|
| 281 |
+
stalls.push({ m: m.clone(), kind, dressed: false })
|
| 282 |
if (kind === 0 || kind === 3) {
|
| 283 |
// baskets of produce
|
| 284 |
for (let k = 0; k < 3; k++) {
|
|
|
|
| 470 |
mesh.receiveShadow = true
|
| 471 |
if (!reflect) mesh.layers.set(1)
|
| 472 |
group.add(mesh)
|
| 473 |
+
return mesh
|
| 474 |
}
|
| 475 |
add(stone, stoneMat)
|
| 476 |
add(wood, woodMat)
|
|
|
|
| 478 |
add(red, redMat)
|
| 479 |
add(roof, roofMat)
|
| 480 |
// market goods: small, main view only
|
| 481 |
+
const proceduralGoods = [add(goods, fruitMat, false, false), add(basket, basketMat, false, false), add(jars, jarMat, false, false)].filter((m) => !!m) as THREE.Mesh[]
|
|
|
|
|
|
|
| 482 |
add(meat, meatMat, true, false)
|
| 483 |
add(awning, awningMat, true, false)
|
| 484 |
|
| 485 |
return {
|
| 486 |
+
group, wheel, stalls, proceduralGoods,
|
| 487 |
wheelInfo: { center: wheelC, axis: wheelAxis, radius: wheelR, tangent: wheelTan },
|
| 488 |
bridgeInfo: { center: V3(bp.x, 0, bp.z), across, along, crestY: crest, halfLen, deckY },
|
| 489 |
lanternSpots, boats, movingBoat, cloths, pavilion: pv,
|
tools/jiangnan/simready_search.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Query NVIDIA SimReady Central (https://nvidia-simready-central.hf.space/) and list the catalogue.
|
| 2 |
+
# usage: simready_search.py "<text query>" [limit] | simready_search.py --catalog (dump every asset to catalog.json)
|
| 3 |
+
import json, sys, urllib.request, collections
|
| 4 |
+
API = "https://nvidia-simready-central.hf.space/search_hybrid"
|
| 5 |
+
def call(body):
|
| 6 |
+
req = urllib.request.Request(API, data=json.dumps(body).encode(), headers={"Content-Type": "application/json", "x-usdsearch-storage-backend": "simready-assets"})
|
| 7 |
+
return json.load(urllib.request.urlopen(req, timeout=120))
|
| 8 |
+
def props(h):
|
| 9 |
+
return {x["name"]: x.get("value") for x in h["source"].get("usd_properties", [])}
|
| 10 |
+
if sys.argv[1] == "--catalog":
|
| 11 |
+
items, cursor = [], None
|
| 12 |
+
while True:
|
| 13 |
+
body = {"description": "object", "limit": 500, "return_usd_properties": True, "filter_by_properties": "simready_metadata_asset_name="}
|
| 14 |
+
if cursor: body["cursor"] = cursor
|
| 15 |
+
r = call(body)
|
| 16 |
+
for h in r["hits"]:
|
| 17 |
+
p = props(h)
|
| 18 |
+
items.append({"name": p.get("simready_metadata_asset_name"), "cat": p.get("simready_metadata_category"), "ext": p.get("simready_metadata_asset_extents"), "lic": p.get("simready_metadata_asset_license"), "author": p.get("simready_metadata_author"), "path": h["source"]["path"]})
|
| 19 |
+
cursor = r.get("next_cursor")
|
| 20 |
+
if not r.get("has_more") or not cursor: break
|
| 21 |
+
json.dump(items, open("catalog.json", "w"), ensure_ascii=False, indent=1)
|
| 22 |
+
print(len(items), "assets;", collections.Counter(i["cat"] for i in items).most_common(25))
|
| 23 |
+
else:
|
| 24 |
+
r = call({"description": sys.argv[1], "limit": int(sys.argv[2]) if len(sys.argv) > 2 else 10, "return_usd_properties": True})
|
| 25 |
+
for h in r["hits"]:
|
| 26 |
+
p = props(h)
|
| 27 |
+
print(f'{h["score"]:.3f} {p.get("simready_metadata_asset_name")!s:40s} {p.get("simready_metadata_category")!s:14s} {p.get("simready_metadata_asset_license")!s:10s} {h["source"]["path"]}')
|
tools/jiangnan/usd2glb.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal USD (UsdPreviewSurface) → GLB converter for SimReady assets: triangulates render meshes,
|
| 2 |
+
# bakes world transforms, Z-up → Y-up, downsizes textures, packs metallic/roughness for glTF.
|
| 3 |
+
import sys, json, struct, io, os
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from pxr import Usd, UsdGeom, UsdShade, Gf
|
| 7 |
+
|
| 8 |
+
src, dst, texsize = sys.argv[1], sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 512
|
| 9 |
+
st = Usd.Stage.Open(src)
|
| 10 |
+
zup = UsdGeom.GetStageUpAxis(st) == 'Z'
|
| 11 |
+
mpu = UsdGeom.GetStageMetersPerUnit(st) or 1.0
|
| 12 |
+
cache = UsdGeom.XformCache(Usd.TimeCode.Default())
|
| 13 |
+
|
| 14 |
+
def tex_path(shader_prim, inp):
|
| 15 |
+
"""follow a UsdPreviewSurface input to the file of a UsdUVTexture, if any"""
|
| 16 |
+
i = UsdShade.Shader(shader_prim).GetInput(inp)
|
| 17 |
+
if not i: return None
|
| 18 |
+
srcs = i.GetConnectedSources()[0] if hasattr(i, 'GetConnectedSources') else []
|
| 19 |
+
for s in srcs:
|
| 20 |
+
sh = UsdShade.Shader(s.source.GetPrim())
|
| 21 |
+
if sh.GetIdAttr().Get() == 'UsdUVTexture':
|
| 22 |
+
f = sh.GetInput('file').Get()
|
| 23 |
+
if f: return f.resolvedPath or f.path
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
materials = {} # mat path -> dict
|
| 27 |
+
def material_for(prim):
|
| 28 |
+
b = UsdShade.MaterialBindingAPI(prim).ComputeBoundMaterial()[0]
|
| 29 |
+
if not b: return None
|
| 30 |
+
key = str(b.GetPath())
|
| 31 |
+
if key in materials: return key
|
| 32 |
+
surf = b.ComputeSurfaceSource()[0] if hasattr(b, 'ComputeSurfaceSource') else None
|
| 33 |
+
info = {'base': None, 'normal': None, 'rough': None, 'metal': None, 'color': [1, 1, 1], 'roughness': 0.6, 'metallic': 0.0}
|
| 34 |
+
if surf:
|
| 35 |
+
p = surf.GetPrim()
|
| 36 |
+
info['base'] = tex_path(p, 'diffuseColor'); info['normal'] = tex_path(p, 'normal')
|
| 37 |
+
info['rough'] = tex_path(p, 'roughness'); info['metal'] = tex_path(p, 'metallic')
|
| 38 |
+
s = UsdShade.Shader(p)
|
| 39 |
+
c = s.GetInput('diffuseColor').Get() if s.GetInput('diffuseColor') else None
|
| 40 |
+
if c and not info['base']: info['color'] = [float(c[0]), float(c[1]), float(c[2])]
|
| 41 |
+
r = s.GetInput('roughness').Get() if s.GetInput('roughness') else None
|
| 42 |
+
if isinstance(r, float): info['roughness'] = r
|
| 43 |
+
m = s.GetInput('metallic').Get() if s.GetInput('metallic') else None
|
| 44 |
+
if isinstance(m, float): info['metallic'] = m
|
| 45 |
+
materials[key] = info
|
| 46 |
+
return key
|
| 47 |
+
|
| 48 |
+
meshes = []
|
| 49 |
+
for prim in st.Traverse():
|
| 50 |
+
if prim.GetTypeName() != 'Mesh': continue
|
| 51 |
+
img = UsdGeom.Imageable(prim)
|
| 52 |
+
if img.ComputePurpose() not in ('default', 'render'): continue
|
| 53 |
+
if img.ComputeVisibility() == 'invisible': continue
|
| 54 |
+
path = str(prim.GetPath()).lower()
|
| 55 |
+
if 'collision' in path or 'physics' in path or 'proxy' in path: continue
|
| 56 |
+
m = UsdGeom.Mesh(prim)
|
| 57 |
+
pts = np.array(m.GetPointsAttr().Get(), dtype=np.float64)
|
| 58 |
+
if len(pts) == 0: continue
|
| 59 |
+
fvc = np.array(m.GetFaceVertexCountsAttr().Get(), dtype=np.int64)
|
| 60 |
+
fvi = np.array(m.GetFaceVertexIndicesAttr().Get(), dtype=np.int64)
|
| 61 |
+
uvp = UsdGeom.PrimvarsAPI(prim).GetPrimvar('st')
|
| 62 |
+
uv = None; uvInterp = None; uvIdx = None
|
| 63 |
+
if uvp and uvp.HasValue():
|
| 64 |
+
uv = np.array(uvp.Get(), dtype=np.float64); uvInterp = uvp.GetInterpolation()
|
| 65 |
+
if uvp.IsIndexed(): uvIdx = np.array(uvp.GetIndices(), dtype=np.int64)
|
| 66 |
+
nrm = m.GetNormalsAttr().Get(); nInterp = m.GetNormalsInterpolation() if nrm else None
|
| 67 |
+
nrm = np.array(nrm, dtype=np.float64) if nrm else None
|
| 68 |
+
xf = cache.GetLocalToWorldTransform(prim)
|
| 69 |
+
M = np.array([[xf[r][c] for c in range(4)] for r in range(4)]) # row-major, row vectors
|
| 70 |
+
# triangulate (fan), expand to flat vertex list
|
| 71 |
+
P, N, T = [], [], []
|
| 72 |
+
fi = 0
|
| 73 |
+
for f, cnt in enumerate(fvc):
|
| 74 |
+
idx = fvi[fi:fi + cnt]
|
| 75 |
+
for k in range(1, cnt - 1):
|
| 76 |
+
for corner in (0, k, k + 1):
|
| 77 |
+
vi = idx[corner]; ci = fi + corner
|
| 78 |
+
P.append(pts[vi])
|
| 79 |
+
if uv is not None:
|
| 80 |
+
if uvInterp == 'faceVarying': u = uv[uvIdx[ci]] if uvIdx is not None else uv[ci]
|
| 81 |
+
elif uvInterp == 'vertex' or uvInterp == 'varying': u = uv[uvIdx[vi]] if uvIdx is not None else uv[vi]
|
| 82 |
+
else: u = [0, 0]
|
| 83 |
+
T.append(u)
|
| 84 |
+
if nrm is not None:
|
| 85 |
+
if nInterp == 'faceVarying': N.append(nrm[ci])
|
| 86 |
+
elif nInterp in ('vertex', 'varying'): N.append(nrm[vi])
|
| 87 |
+
elif nInterp == 'uniform': N.append(nrm[f])
|
| 88 |
+
fi += cnt
|
| 89 |
+
P = np.array(P); P1 = np.c_[P, np.ones(len(P))] @ M; P = P1[:, :3] * mpu
|
| 90 |
+
if nrm is not None and len(N) == len(P):
|
| 91 |
+
N = np.array(N) @ np.linalg.inv(M[:3, :3]).T
|
| 92 |
+
else:
|
| 93 |
+
N = np.zeros_like(P)
|
| 94 |
+
for i in range(0, len(P), 3):
|
| 95 |
+
n = np.cross(P[i + 1] - P[i], P[i + 2] - P[i]); N[i:i + 3] = n
|
| 96 |
+
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-9)
|
| 97 |
+
if zup:
|
| 98 |
+
P = P[:, [0, 2, 1]] * [1, 1, -1]; N = N[:, [0, 2, 1]] * [1, 1, -1]
|
| 99 |
+
# flip winding? Z->Y mapping (x,y,z)->(x,z,-y) is a proper rotation, so no.
|
| 100 |
+
T = np.array(T) if len(T) == len(P) else np.zeros((len(P), 2))
|
| 101 |
+
T[:, 1] = 1 - T[:, 1]
|
| 102 |
+
# decimate (texture-preserving quadric edge collapse) when the mesh is far heavier than a web prop needs
|
| 103 |
+
target = int(os.environ.get('TARGET_TRIS', '6000'))
|
| 104 |
+
if len(P) // 3 > target * 1.3:
|
| 105 |
+
import pymeshlab
|
| 106 |
+
ms = pymeshlab.MeshSet()
|
| 107 |
+
faces = np.arange(len(P)).reshape(-1, 3)
|
| 108 |
+
ms.add_mesh(pymeshlab.Mesh(vertex_matrix=P.astype(np.float64), face_matrix=faces, v_normals_matrix=N.astype(np.float64), v_tex_coords_matrix=T.astype(np.float64)))
|
| 109 |
+
ms.apply_filter('compute_texcoord_transfer_vertex_to_wedge')
|
| 110 |
+
ms.apply_filter('meshing_remove_duplicate_vertices')
|
| 111 |
+
ms.apply_filter('meshing_decimation_quadric_edge_collapse_with_texture', targetfacenum=target, qualitythr=0.4, preserveboundary=True, planarquadric=True)
|
| 112 |
+
ms.apply_filter('compute_normal_per_vertex')
|
| 113 |
+
mm = ms.current_mesh()
|
| 114 |
+
V, F = mm.vertex_matrix(), mm.face_matrix()
|
| 115 |
+
VN = mm.vertex_normal_matrix()
|
| 116 |
+
WT = mm.wedge_tex_coord_matrix() # per-corner uv (3 per face)
|
| 117 |
+
P = V[F.reshape(-1)]; N = VN[F.reshape(-1)]; T = WT.reshape(-1, 2)
|
| 118 |
+
# weld
|
| 119 |
+
key = np.round(np.c_[P, N, T], 5)
|
| 120 |
+
_, first, inv = np.unique(key, axis=0, return_index=True, return_inverse=True)
|
| 121 |
+
order = np.argsort(first); remap = np.empty_like(order); remap[order] = np.arange(len(order))
|
| 122 |
+
inv = remap[inv.reshape(-1)]
|
| 123 |
+
Pw, Nw, Tw = P[first[order]], N[first[order]], T[first[order]]
|
| 124 |
+
meshes.append((material_for(prim), Pw.astype(np.float32), Nw.astype(np.float32), Tw.astype(np.float32), inv.astype(np.uint32)))
|
| 125 |
+
|
| 126 |
+
# --- textures
|
| 127 |
+
def load(p, mode):
|
| 128 |
+
if not p or not os.path.exists(p): return None
|
| 129 |
+
im = Image.open(p).convert(mode); im.thumbnail((texsize, texsize), Image.LANCZOS); return im
|
| 130 |
+
images, imgbytes = [], []
|
| 131 |
+
def add_image(im, fmt):
|
| 132 |
+
b = io.BytesIO(); im.save(b, fmt, quality=88) if fmt == 'JPEG' else im.save(b, fmt, optimize=True)
|
| 133 |
+
images.append({'mimeType': 'image/jpeg' if fmt == 'JPEG' else 'image/png'}); imgbytes.append(b.getvalue()); return len(images) - 1
|
| 134 |
+
mat_index = {}; gmats = []
|
| 135 |
+
for key, info in materials.items():
|
| 136 |
+
gm = {'name': key.split('/')[-1], 'pbrMetallicRoughness': {'baseColorFactor': info['color'] + [1], 'metallicFactor': info['metallic'] if not info['metal'] else 1.0, 'roughnessFactor': info['roughness'] if not info['rough'] else 1.0}}
|
| 137 |
+
base = load(info['base'], 'RGB')
|
| 138 |
+
if base: gm['pbrMetallicRoughness']['baseColorTexture'] = {'index': add_image(base, 'JPEG')}; gm['pbrMetallicRoughness']['baseColorFactor'] = [1, 1, 1, 1]
|
| 139 |
+
rough, metal = load(info['rough'], 'L'), load(info['metal'], 'L')
|
| 140 |
+
if rough or metal:
|
| 141 |
+
sz = (rough or metal).size
|
| 142 |
+
mr = Image.merge('RGB', [Image.new('L', sz, 255), rough or Image.new('L', sz, int(info['roughness'] * 255)), metal or Image.new('L', sz, int(info['metallic'] * 255))])
|
| 143 |
+
gm['pbrMetallicRoughness']['metallicRoughnessTexture'] = {'index': add_image(mr, 'JPEG')}
|
| 144 |
+
normal = load(info['normal'], 'RGB')
|
| 145 |
+
if normal: gm['normalTexture'] = {'index': add_image(normal, 'JPEG')}
|
| 146 |
+
mat_index[key] = len(gmats); gmats.append(gm)
|
| 147 |
+
|
| 148 |
+
# --- buffers
|
| 149 |
+
bin_parts = []; views = []; accessors = []; gmeshes = []; nodes = []
|
| 150 |
+
def push(data, target=None):
|
| 151 |
+
off = sum(len(p) for p in bin_parts)
|
| 152 |
+
while off % 4: bin_parts.append(b'\0'); off += 1
|
| 153 |
+
bin_parts.append(data); v = {'buffer': 0, 'byteOffset': off, 'byteLength': len(data)}
|
| 154 |
+
if target: v['target'] = target
|
| 155 |
+
views.append(v); return len(views) - 1
|
| 156 |
+
for mi, (mk, P, N, T, I) in enumerate(meshes):
|
| 157 |
+
pv = push(P.tobytes(), 34962); nv = push(N.tobytes(), 34962); tv = push(T.tobytes(), 34962); iv = push(I.tobytes(), 34963)
|
| 158 |
+
accessors += [
|
| 159 |
+
{'bufferView': pv, 'componentType': 5126, 'count': len(P), 'type': 'VEC3', 'min': P.min(0).tolist(), 'max': P.max(0).tolist()},
|
| 160 |
+
{'bufferView': nv, 'componentType': 5126, 'count': len(N), 'type': 'VEC3'},
|
| 161 |
+
{'bufferView': tv, 'componentType': 5126, 'count': len(T), 'type': 'VEC2'},
|
| 162 |
+
{'bufferView': iv, 'componentType': 5125, 'count': len(I), 'type': 'SCALAR'}]
|
| 163 |
+
a = len(accessors) - 4
|
| 164 |
+
prim = {'attributes': {'POSITION': a, 'NORMAL': a + 1, 'TEXCOORD_0': a + 2}, 'indices': a + 3}
|
| 165 |
+
if mk in mat_index: prim['material'] = mat_index[mk]
|
| 166 |
+
gmeshes.append({'primitives': [prim]}); nodes.append({'mesh': mi})
|
| 167 |
+
for i, b in enumerate(imgbytes):
|
| 168 |
+
images[i]['bufferView'] = push(b)
|
| 169 |
+
binblob = b''.join(bin_parts)
|
| 170 |
+
while len(binblob) % 4: binblob += b'\0'
|
| 171 |
+
gltf = {'asset': {'version': '2.0', 'generator': 'usd2glb (暮归江南)'}, 'scene': 0, 'scenes': [{'nodes': list(range(len(nodes)))}], 'nodes': nodes, 'meshes': gmeshes,
|
| 172 |
+
'materials': gmats, 'images': images, 'textures': [{'source': i, 'sampler': 0} for i in range(len(images))], 'samplers': [{'magFilter': 9729, 'minFilter': 9987, 'wrapS': 10497, 'wrapT': 10497}],
|
| 173 |
+
'accessors': accessors, 'bufferViews': views, 'buffers': [{'byteLength': len(binblob)}]}
|
| 174 |
+
if not images: gltf.pop('images'); gltf.pop('textures'); gltf.pop('samplers')
|
| 175 |
+
js = json.dumps(gltf, separators=(',', ':')).encode()
|
| 176 |
+
while len(js) % 4: js += b' '
|
| 177 |
+
out = b'glTF' + struct.pack('<II', 2, 12 + 8 + len(js) + 8 + len(binblob)) + struct.pack('<II', len(js), 0x4E4F534A) + js + struct.pack('<II', len(binblob), 0x004E4942) + binblob
|
| 178 |
+
open(dst, 'wb').write(out)
|
| 179 |
+
allP = np.concatenate([m[1] for m in meshes]); tris = sum(len(m[4]) // 3 for m in meshes)
|
| 180 |
+
print(f'{os.path.basename(dst)}: meshes={len(meshes)} tris={tris} verts={len(allP)} size={len(out)//1024}KB bbox_min={allP.min(0).round(3).tolist()} bbox_max={allP.max(0).round(3).tolist()} mats={len(gmats)} imgs={len(images)}')
|