Genji Crowd API

v1
EN 中文

快速开始 — 只需两步:

1. API 密钥:YOUR_API_KEY

2. curl -H "Authorization: Bearer YOUR_API_KEY" https://www.genjicrowd.com/v1/crowd/cr_sample01

Genji 人群 API — v1

合作伙伴集成参考文档(适用于 Tuanjie / Unity 原生渲染)。


版本说明

本文档描述 API v1,版本号体现在 URL 前缀中(/v1/)。 破坏性变更(字段移除、重命名或语义变更)将使版本升级至 /v2/, 届时 v1 将提前公告废弃,不会立即下线。 非破坏性变更(新增可选响应字段)不会更改版本号。


概述

Genji 人群 API 允许引擎加载已保存的人群配方(recipe)——即在 Genji 编辑器中创作的人群所需的完整配置——以及预先解析好的每个角色的部件分配和直接资源 URL。

典型集成流程:

GET /v1/crowd/{crowd_id}
  → recipe(人群配置)
  → characters[](体型 / 面部 / 发型 ID,位置,动画状态)
  → assets(GLB URL,动画 GLB URL)

引擎随后:

  1. 下载一次 assets.skeleton_glb(包含共享骨架上的所有部件网格)。
  2. characters[] 中的每个条目,选择对应网格节点并应用每个角色的颜色、缩放和动画相位。
  3. 使用配方中的边界 / 碰撞参数运行自己的智能体模拟。

资源托管

所有 GLB 和动画文件由响应请求的服务器提供。 资源 URL 已完整解析并包含在每次 API 响应中——您无需自行构建。

环境 资源来源
本地开发 服务器自身主机(如 http://localhost:3001
ngrok 隧道 隧道 URL(如 https://genji-api.ngrok.app
生产环境 由部署团队配置(Cloudflare R2 / CDN)

请勿硬编码资源 URL。 始终从 response.assets.* 中读取。 服务器在请求时构建完整解析的 URL,反映当前运行位置。 将本文档中的示例 URL 复制到代码中将指向错误的主机,请直接使用 assets.skeleton_glbassets.animations.walk 等字段。


鉴权

每个请求必须在 Authorization 头中携带 Bearer 令牌:

Authorization: Bearer gk_live_…

密钥通过 Genji 控制台(设置 → API 密钥)或由部署团队签发。 原始密钥仅在创建时显示一次,不以明文形式存储。

错误响应:

状态码 响应体 原因
401 {"error":"Missing Authorization: Bearer <api_key>"} 缺少请求头
401 {"error":"Invalid or revoked api key"} 密钥错误或已吊销
404 {"error":"Crowd not found"} crowd_id 不存在

端点

`GET /v1/crowd/{crowd_id}`

返回一个已保存人群的完整配方、预解析角色列表和资源 URL。

路径参数

参数 类型 说明
crowd_id string 不透明 ID,格式为 cr_[a-zA-Z0-9]{8}

响应 `200 OK` — `application/json`

{
  "crowd_id":    "cr_sample01",          // string — 与请求路径匹配
  "name":        "Imperial Crowd — Tuanjie Sample",
  "collection":  "genji-v1",             // string — 集合标签(可为 "")
  "recipe":      { ... },                // object — 见下方配方结构
  "characters":  [ ... ],               // array  — 见下方角色对象
  "shared_parts": ["Eye_L","Eye_R","L_Hand","R_Hand","L_Foot","R_Foot"],
  "assets":      { ... },                // object — 见下方资源 URL
  "updated_at":  "2026-06-24 10:00:00"  // string — ISO 风格 UTC 时间戳
}

配方结构(Recipe Schema)

recipe 是一个包含以下字段的 JSON 对象。

核心字段

字段 类型 说明
v integer 配方版本,当前为 1
seed integer 主 RNG 种子,决定所有角色分配和初始位置(确定性)。
size integer 人群中的角色总数。
weights object 桶权重映射 — { bucketId: number },控制服装原型的混合比例。桶 ID 见 能力目录(Capabilities)

行走 / 动画

字段 类型 说明
walkSpeedMin float 最小行走速度倍数(相对于动画片段速度),默认 0.6
walkSpeedMax float 最大行走速度倍数,默认 1.0
walkRatio float 旧版字段(已被 pace 取代)。
pace float 0–1 行走角色占总数的比例。0 = 全部待机,1 = 全部行走,默认 0.5
crowdRate float 流动 / 道路模式下每分钟进入的角色数。

布局

字段 类型 说明
boundaryShape string 人群边界形状:"circle""square""road""donut""spline"
boundaryRadius float 外边界半径(米)。road 模式下为半长。
spawnRadius float 内部生成半径(米)。road 模式下为半宽。
splinePoints [[x,z], …] | null spline 形状的控制点(世界空间 XZ)。其他形状为 null
facing float 0–1 0 = 随机朝向,1 = 全部朝同一方向。
distribution string "random""grid",控制边界内位置的分布方式。

地形

字段 类型 说明
terrain.active boolean true = 启用程序化地形。
terrain.amplitude float 地形高度振幅(米)。
terrain.frequency float 地形噪声频率。
terrain.noiseType string "fractal""simplex"

颜色变化

所有服装颜色均为桶特定的 HSL 值,并为每个角色随机抖动。

字段 类型 说明
hueShift float 应用于所有角色服装的全局色相旋转。
hueVar float 每角色色相抖动范围 ±hueVar
satVar float 每角色饱和度抖动范围 ±satVar
lightVar float 每角色亮度抖动范围 ±lightVar

碰撞 / 避让

这些参数输入到您自己的智能体模拟引擎中。

字段 类型 说明
collisionRadius float NPC 体积半径倍数(× 角色高度)。两个角色距离 = 2 × 半径 × 高度时视为"接触"。
personalSpace float 向心力 / 路径跟随力强度(01)。
anticipation float 0–1 NPC 开始躲避碰撞的提前程度。0 = 接触时才反应,1 = 提前转向。

障碍物

"obstacles": [
  { "type": "rock", "x": 20.0, "z": 15.0, "radius": 3.5 },
  ...
]

每个障碍物为世界空间 XZ 中的圆柱(Y 轴无限延伸)。角色会绕开以 (x, z) 为圆心、半径为 radius 米的圆盘区域。

效应器

字段 类型 说明
groupingMode boolean true 时,角色聚集成群组。
attractActive boolean 全局吸引器是否激活。
attractTarget {x, z} 吸引器的世界空间 XZ 位置。

渲染标志

这些是创作工具中的视口开关,供渲染器参考使用。

字段 类型 说明
textures boolean 保存时作者已开启纹理。
collisionEnabled boolean NPC 间避让已启用。
jogBlendEnabled boolean 行走→慢跑混合已启用。
lazyGPU boolean 内部 GPU 更新优化标志。
collection string 集合标签(如 "genji-v1")。

角色对象(Character Object)

characters[] 中的每个条目为一个已预解析部件 ID 的 NPC。

{
  "index":        0,                              // integer — 0 起的角色索引
  "bucket":       "soldiers",                     // string  — 原型桶 ID
  "body_id":      "M_Terracotta_Infantry_Soldier",// string  — skeleton_glb 中的 SkinnedMesh 节点名
  "face_id":      "M_Chinese_Face_03",            // string  — skeleton_glb 中的 SkinnedMesh 节点名
  "hair_id":      "Ming_Warrior_Crown",           // string  — skeleton_glb 中的 SkinnedMesh 节点名
  "x":            12.3400,                        // float   — 初始 X 坐标(米,世界空间)
  "z":            -5.7100,                        // float   — 初始 Z 坐标(米,世界空间)
  "rot_y":        1.2340,                         // float   — Y 轴初始旋转(弧度)
  "scale":        1.0230,                         // float   — 统一缩放倍数
  "phase":        0.6710,                         // float 0–1 — 动画相位偏移(避免集体同步)
  "anim_clip":    "walk",                         // string  — 生成时的动画:"idle" 或 "walk"
  "idle_variant": 2,                              // integer 0–7 — 待机动画变体编号
  "garment_hsl":  [0.0521, 0.5182, 0.4830],      // [H, S, L] 归一化 0–1 — 每角色服装色调
  "skin_hex":     "#ddb892",                      // string  — 皮肤颜色十六进制
  "hair_hex":     "#1a1410"                       // string  — 发色十六进制
}

共享部件

body_idface_idhair_id 外,每个角色还渲染六个共享网格节点(眼睛、手、脚)。 这些对所有角色相同,列于顶层 shared_parts 数组中:

["Eye_L", "Eye_R", "L_Hand", "R_Hand", "L_Foot", "R_Foot"]

坐标系

API 响应中的所有位置、距离和旋转均使用以下约定:

属性
手性 右手坐标系
向上轴 Y
单位
地面平面 XZ(Y = 地形高度,平地上为 0)
rot_y = 0 角色朝向 +Z
rot_y 从上方(+Y)俯视逆时针旋转

角色位置以 (x, z) 对给出(Y 始终为地面高度)。 将角色放置于世界坐标 (x, groundHeight, z),并应用 Y 轴旋转 rot_y 弧度。

角色朝向方向向量:

forward_x = sin(rot_y)
forward_z = cos(rot_y)

与 GLB 资源一致:skeleton_glb 和所有动画 GLB 均以 glTF 约定导出(右手系,Y 向上,米)。加载时无需进行轴交换。

开箱即用即为米制。 API 响应中的 x/z(角色位置)以米为单位,GLB 场景加载后同样以米为单位: 源骨架以 Maya 厘米制作,但导出时已将 0.01 缩放烘焙进 GLB 根节点。 请勿再做任何单位换算 —— 加载后的角色身高约 1.7 米。 加载后自检:约 1.7 = 正确;约 170 = 你的导入器丢弃了根节点缩放(需自行在根节点应用 0.01); 约 0.017 = 你额外应用了一次 0.01(必须移除)。

Unity / 左手引擎: Unity 使用左手 Y 向上坐标系。 转换方式:取反 zposition.z = -C.z,取反 rot_yrotation.y = -C.rot_y。 转换方向向量时同样取反 forward_z

rot_y 是出生时刻的朝向快照,不是运动方向向量。 它根据配方的出生区域形状 一次性生成(例如圆形边界时基本是随机值)。如果你自行实现了角色运动逻辑 (自定义路径、让角色沿街道行走、转向/避让等)——请每帧根据自己的速度矢量 重新计算朝向,不要对自行移动的角色继续使用 API 提供的静态 rot_yrot_y = atan2(velocity.x, velocity.z)。仅当角色为 idle(始终静止)或你在 原样复现配方描述的出生布局、未额外添加自定义运动时,才可直接使用 API 返回的 rot_y


资源 URL

// ⚠️  这些 URL 由服务器返回——不由客户端构建。
// 主机名随环境变化(本地开发 / ngrok / 生产 CDN)。
// 始终使用响应中的值,切勿硬编码基础 URL。
"assets": {
  "unit":            "m",
  "unit_scale":      1.0,
  "skeleton_glb":    "{base}/assets/Basemesh31.glb",
  "lod_glb":         "{base}/assets/Basemesh31_LOD1.glb",
  "capabilities_json": "{base}/v1/capabilities",
  "animations": {
    "idle":              "{base}/assets/A_Idle.glb",
    "idle_arms_crossed": "{base}/assets/A_Idle_ArmsCrossed.glb",
    "idle_hands_hip":    "{base}/assets/A_Idle_HandsHip.glb",
    "idle_clasp":        "{base}/assets/A_Idle_Clasp.glb",
    "idle_shift":        "{base}/assets/A_Idle_Shift.glb",
    "idle_look_around":  "{base}/assets/A_Idle_LookAround.glb",
    "idle_scratch":      "{base}/assets/A_Idle_Scratch.glb",
    "idle_tired":        "{base}/assets/A_Idle_Tired.glb",
    "walk":              "{base}/assets/A_WalkFwd.glb",
    "jog":               "{base}/assets/A_JogFwd.glb",
    "walk_stop":         "{base}/assets/A_WalkFwd_Stop.glb"
  }
}

部件 ID 与资源的映射关系

所有角色部件均存于单一 GLB 文件中——skeleton_glb

每个部件 ID(如 M_Terracotta_Infantry_Soldier)是该文件中 SkinnedMesh 节点的名称。渲染角色的步骤:

  1. 加载一次 skeleton_glb,其中包含一个共享骨架及每个部件对应的 SkinnedMesh 节点。
  2. characters[] 中的每个角色,将以下网格节点设为可见
    • body_id 节点
    • face_id 节点
    • hair_id 节点
    • 全部 6 个 shared_parts 节点(眼睛、手、脚)
    • 所有其他网格节点:隐藏
  3. 实例化或克隆可见集,应用角色的 scalerot_y(x, z) 位置。
  4. 应用每角色颜色(见下方颜色应用)。
  5. 播放 anim_clip 指定的动画片段,偏移量为 phase × clip_duration

lod_glb 是一个独立的第二份 GLB 文件——节点名称、共享骨架、缩放/原点行为均与 skeleton_glb 相同,仅使用更低面数的网格(文件大小约为原版的 40%)。它不是 skeleton_glb 内部的网格变体,需要单独加载并以相同方式克隆。对于此规模的人群, 一个合理的默认策略是:距相机约 15–20 米内使用高精度 skeleton_glb,超出此距离 切换为 lod_glb

每个角色任意时刻只需保留一份激活的克隆,不需要同时维护两份。 切换成本很低: 当角色越过 LOD 距离阈值时,替换其克隆体(高精度 ↔ LOD),但将当前动画片段和 播放时间带到新克隆的 mixer 上newAction.time = oldAction.time),而不是从 0 重新开始。"不要在动画播放过程中热切换" 说的正是这一点——切换时保留播放进度。 这并不意味着要让两种精度的角色同时并行运行;那样会让每个角色始终占用双倍 内存和双倍动画计算,而实际上任意时刻只有一部分角色需要做距离判断。

capabilities_jsonGET /v1/capabilities)列出可用的形状、可加权的桶 id/标签,以及每个部件的 id、标签、组别和性别,适合用于构建 UI 或验证工具。

颜色应用

每个角色有三个颜色输入:

输入 来源 应用目标
garment_hsl 每角色 HSL(0–1 归一化) 身体网格 —— 直接设置:material.color.setHSL(h, s, l)
skin_hex 十六进制字符串 面部 + 共享皮肤网格
hair_hex 十六进制字符串 发型网格

garment_hsl完整的最终服装颜色,由服务端计算。请直接用 material.color.setHSL(h, s, l) 应用 —— 切勿从其他来源推导或叠加。 另外请先为每个角色克隆材质再着色,否则所有角色会共享同一颜色。


GPU 实例化渲染(骨骼纹理)

上文描述的默认渲染路径(SkeletonUtils.clone() + 每角色一个 AnimationMixer)实现 简单,但扩展性有限 —— 根据硬件不同,大约在 150–300 个角色规模就会跌出流畅帧率 (实测数据:150–5000 个独立克隆角色时帧率为 17–24 fps)。

对于 1000+ 角色的人群,请改用 assets.bone_tex_bin + assets.bone_tex_manifest: 一份共享的 GPU 纹理,编码了每个动画片段的骨骼矩阵,在自定义蒙皮着色器中逐顶点采样, 通过每个身体/面部/发型部件一个 THREE.InstancedMesh(而非每角色一个)渲染 —— 无论人群规模多大,整个人群通常只需 ≤36 次绘制调用。这与 Genji 人群创作工具自身 用于以流畅帧率渲染数千角色的技术完全相同。

"assets": {
  "bone_tex_bin":      "{base}/assets/bone_tex.bin",
  "bone_tex_manifest":  "{base}/assets/bone_tex_manifest.json"
}

可直接使用的参考实现位于 /sdk/genji-crowd-instanced.js(独立 ES 模块,唯一的 对等依赖是 three),并附带一个可运行示例 /sdk/genji-crowd-instanced-demo.html (追加 ?size=2000 可控制人群规模)。

工作原理

bone_tex_bin 是一份原始 Float32Array 二进制数据(无文件头、无压缩),按 RGBA32F 纹理布局排列:width = bone_tex_manifest.texWidthheight = bone_tex_manifest.texHeight。 每个动画片段占据一段连续的纹理列(每帧一列,30 fps 烘焙);每根骨骼占据 4 行纹理 (一个 mat4,存为 4 个 vec4 行)。你的着色器逐顶点采样该纹理,按 skinWeight 混合 4 根影响骨骼,重建出与 SkeletonUtils.clone() 在 CPU 上计算结果完全一致的蒙皮 位置 —— 只是这次是在 GPU 上为一个部件的所有实例统一计算一次,而不是每个克隆角色 在 CPU 上各算一次。

使用时无需理解这些细节 —— 参考 SDK 中的 loadBoneTexture() 会为你构建 THREE.DataTexture,内置着色器已完成采样逻辑。

bone_tex_manifest 字段

字段 类型 含义
texWidth / texHeight int bone_tex_bin 的 RGBA32F 纹理尺寸。
nBones int 骨骼数量(与 skeleton_glb 的骨架一致)。
bakeFps int 动画烘焙时使用的帧率(30)。
format string 固定为 "RGBA32F" —— 每纹素 4 个浮点数,无压缩。
totalBakedFrames int 所有已烘焙片段的总帧数。
numIdle int 待机变体数量(索引 0..numIdle-1,与 idle_variant 对应)。
clipWalk int anim_clip === "walk" 时应使用的片段索引。
clipInfo object 按片段名称索引的 {start, frames, duration}
modelScale number 仅供参考 —— 已烘焙进纹理数据,请勿重复应用。

最简集成示例

import { loadParts, loadBoneTexture, buildCrowdInstances, updateCrowdTime }
  from '/sdk/genji-crowd-instanced.js';

const parts   = await loadParts(response.assets.skeleton_glb);
const boneTex = await loadBoneTexture(response.assets.bone_tex_bin, response.assets.bone_tex_manifest);
const crowd   = buildCrowdInstances(scene, parts, boneTex, response.characters, response.shared_parts);

function animate(dt) {
  updateCrowdTime(crowd, dt);
  renderer.render(scene, camera);
}

适用范围:不支持每角色速度差异

该渲染路径严格实现公开 Character 数据结构中已有的字段 (anim_clip/idle_variant/phase/garment_hsl/skin_hex/hair_hex/x/z/rot_y/scale) —— API 中没有速度或动量字段,因此 walk 始终以其原始烘焙速度播放。角色之间仅通过 phase(相位错开)产生差异,而非播放节奏。这是人群规模渲染下已知且可接受的取舍, 并非缺陷:2000 名步行者组成的人墙,观感会比手工调校、带每角色速度差异的模拟略显 整齐划一。

常见集成故障(骨骼纹理路径)

症状 原因 解决方法
所有角色共享同一姿势 / T-pose loadBoneTexture() 完成前就开始渲染 调用 buildCrowdInstances 前先 await
角色完全不动画(静止不动) uTime uniform 从未更新 每帧调用 updateCrowdTime
所有角色播放同一片段/姿势 未正确写入每实例的 aClip/aPhase 使用 writeCrowdInstances,不要手写实例缓冲区
请求了 jog/run 动画但不播放 不可用 —— 公开 API 仅提供 idle/walk 属预期行为 —— jog 仅供内部使用,不在此 API 范围内
绘制调用数超过约 36 次 按每角色而非每部件构建了 InstancedMesh 应按每个唯一的 body_id/face_id/hair_id/共享部件值各建一个,而非每角色一个

超级优化渲染档位(无纹理 / VAT / BBM)

上述骨骼纹理路径已能以约 36 次绘制调用满帧渲染整个人群。对于超大规模(数万角色) 或显存 / GPU 受限的目标平台(移动端、VR、集成显卡),还有三个档位可以用视觉精度 换取纯吞吐量。这些正是 Genji 工具内部使用的同一批优化。

这三者消耗的都正是 API 已返回的数据 —— assets.skeleton_glb 以及 assets.bone_tex_bin + assets.bone_tex_manifest没有额外接口,也没有额外下载; 每个档位都是你在客户端做出的渲染选择。它们还可以作为 LOD 分带混用(近处用完整网格, 远处用 BBM),而且由于每个档位读取的都是同一份骨骼纹理,角色在切换档位时动画始终保持同步。

一览

档位 绘制调用 逐顶点计算 额外显存 适用场景
完整蒙皮(骨骼纹理) 约 36 4 骨骼采样 + 加权混合 部件纹理 默认;近处相机
无纹理 约 36 4 骨骼混合,无贴图采样 (释放漫反射贴图) 显存受限;风格化;远处分带
VAT 约 36 2–8 次采样,无混合 大(整套约 2.4 GB) ALU / 蒙皮混合受限
BBM 骨骼立方体代理 1 1 次采样 + 1 次矩阵乘法 超大规模;远处 LOD

1. 无纹理(扁平)模式

完全不加载部件的漫反射 / 法线 / 粗糙度贴图,将每个部件渲染为一个受光的纯色(白色, 或每部件一个纯色调)。这会释放全部纹理显存,并从片段着色器中移除每一次纹理采样 —— 是这里最轻量的改动,也很适合远处 LOD 分带或内存有限的设备。

2. VAT —— 顶点动画纹理(Vertex Animation Texture)

蒙皮后的顶点位置(即骨骼纹理着色器计算出的完全相同的结果)预烘焙到一张按 [绝对帧, 顶点ID] 索引的纹理中。运行时顶点着色器只做一次查表并在两帧之间插值,而不再 混合四根骨骼矩阵:

// f0,f1 = 夹住当前时间的两个帧列;fr = 混合系数
vec3 pos = mix( texelFetch(uVAT, ivec2(f0, gl_VertexID), 0).xyz,
                texelFetch(uVAT, ivec2(f1, gl_VertexID), 0).xyz, fr );

3. BBM —— 骨骼立方体代理(超大规模 LOD)

约 17 个刚性基本体替换每个角色的蒙皮网格 —— 每根骨骼一个立方体 / 圆柱体(四肢圆柱、 两个躯干块、一个头部、双手),每个都固定到单根骨骼上。没有 4 骨骼混合:每顶点只做一次 骨骼矩阵采样。整个人群合并为一个实例化网格 → 1 次绘制调用,是所有档位中最廉价的动画 顶点着色器。

从骨架绑定姿势(skeleton_glb)一次性构建它: 对每根四肢骨骼,构建一个从该骨骼延伸到 其子骨骼的圆柱;躯干和头部则根据髋部 / 肩部 / 头部骨骼的位置拟合。在每个基本体所属骨骼的 局部绑定空间中构建它,然后给它的每个顶点打上那一根骨骼的索引(aBone)。

运行时:同一份骨骼纹理采样那一根骨骼的动画矩阵,并刚性地变换顶点 —— 这是父级 约束,而非蒙皮:

mat4 b = boneMatrix(aBone);        // 从 bone_tex(共享纹理)采样
vec3 world = (b * vec4(pos, 1.0)).xyz * UNIT_SCALE;   // 见下方陷阱

关键的顺序陷阱(踩坑换来的经验)。 骨骼纹理把厘米→米的 0.01 模型缩放烘焙进了 每个矩阵(bone_tex_manifest.modelScale),而立方体几何体以米为单位,因此你要用一个 ×100 来补偿。×100 施加在变换后的点上,而不是输入上: (b * p) * 100绝不能写成 b * (p * 100)。均匀缩放与矩阵的旋转部分可交换,但与其平移部分 不可交换 —— 放错一边,旋转看起来仍然正确,但每根骨骼的姿势偏移被压缩到 1%,于是 任何远离绑定姿势的骨骼(迈步中的小腿、摆动的手臂)的立方体都会脱离身体并漂浮。在 绑定姿势的 T-pose 下一切正常,只有动起来才会崩,因此这个 bug 极易被漏掉又难以定位。


动画片段

共有 8 个待机变体(索引 0–7,anim_clip = "idle")和 1 个行走片段anim_clip = "walk")。使用 idle_variant 选择对应待机 GLB。

anim_clip idle_variant GLB 键
"idle" 0 animations.idle
"idle" 1 animations.idle_arms_crossed
"idle" 2 animations.idle_hands_hip
"idle" 3 animations.idle_clasp
"idle" 4 animations.idle_shift
"idle" 5 animations.idle_look_around
"idle" 6 animations.idle_scratch
"idle" 7 animations.idle_tired
"walk" (任意) animations.walk

所有片段共享 skeleton_glb 中的同一骨架,加载并重定向一次即可。

animations.walk 在根骨骼上烘焙了根运动位移,方向严格沿局部 +Z 轴(已验证: 整个片段中根节点位移从 [0,0,0] 变化到 [0,0,1.7])——与 rot_y = 0 时角色的 朝向完全一致。如果角色看起来在倒着走,问题不在动画片段本身——请检查你自己的 位置/旋转代码(很可能是 atan2 参数顺序颠倒,或从运动方向推导 rot_y 时符号 搞反了)。在用条件性的 + Math.PI 翻转来打补丁之前,请直接从速度矢量重新推导 rot_yrot_y = atan2(velocity.x, velocity.z),并移除所有按分支添加的符号 补丁——在错误公式上叠加条件翻转,通常表现为"修好一半",因为它补偿的是错误的 那个 bug。

phase 字段(0–1)为生成时片段的归一化时间偏移:

startTime = phase × clipDuration   // 单位:秒

这样可避免整个人群动画同步。


能力目录(Capabilities)

GET /v1/capabilities(也在每个人群响应中通过 assets.capabilities_json 提供)是您用于构建请求和 UI 的目录。它列出可用的边界形状、可在 weights 中引用的桶 id/标签,以及每个部件的 id、标签、组别和性别。结构如下:

{
  "shapes": ["circle", "square", "road", "donut", "spline", "polygon"],
  "buckets": [
    { "id": "soldiers", "label": "Soldiers" },
    { "id": "laborers", "label": "Laborers" }
    // ... 每个可加权的原型一个条目
  ],
  "parts": [
    { "id": "M_Terracotta_Infantry_Soldier", "label": "Terracotta Infantry Soldier", "group": "body", "gender": "m" },
    { "id": "M_Chinese_Face_01",             "label": "Male Face 01",                 "group": "face", "gender": "m" },
    { "id": "Ming_Warrior_Crown",            "label": "Ming Warrior Crown",           "group": "hair", "gender": "m" }
    // ... 所有身体、面部和发型部件
  ]
}

完整示例

请求

cURL

curl -s \
  -H "Authorization: Bearer YOUR_API_KEY" \
  https://genji-api.ngrok.app/v1/crowd/cr_sample01

JavaScript

const res = await fetch('https://genji-api.ngrok.app/v1/crowd/cr_sample01', {
  headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.json()).error}`);

const crowd = await res.json();
// crowd.assets.*     — 完整解析的 GLB URL,直接传给加载器
// crowd.characters[] — 每 NPC 的部件 ID、位置、动画片段、颜色
console.log(crowd.characters.length + ' 个角色');
console.log('骨架 GLB:', crowd.assets.skeleton_glb);

Unity (C#)

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class GenjICrowdLoader : MonoBehaviour
{
    [SerializeField] string apiHost = "https://genji-api.ngrok.app";
    [SerializeField] string crowdId = "cr_sample01";
    [SerializeField] string apiKey  = "YOUR_API_KEY";

    void Start() => StartCoroutine(LoadCrowd());

    IEnumerator LoadCrowd()
    {
        using var req = UnityWebRequest.Get($"{apiHost}/v1/crowd/{crowdId}");
        req.SetRequestHeader("Authorization", $"Bearer {apiKey}");
        yield return req.SendWebRequest();

        if (req.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError($"[Genji] 人群加载失败: {req.error}");
            yield break;
        }

        // crowd.assets.skeleton_glb  — 使用 GLTFast / Piglet / 自定义 GLB 加载器加载
        // crowd.characters[]         — 每 NPC 的部件 ID、位置、动画、颜色
        string json = req.downloadHandler.text;
        Debug.Log($"[Genji] 响应: {json.Length} 字节");

        // 下一步:将 json 解析为 CrowdResponse 类并下载资源
    }
}

响应 `200 OK`(缩略版——完整响应含 150 个角色)

{
  "crowd_id": "cr_sample01",
  "name": "Imperial Crowd — Tuanjie Sample",
  "collection": "genji-v1",
  "recipe": {
    "v": 1,
    "seed": 42,
    "size": 150,
    "weights": {
      "laborers": 40,
      "merchants": 10,
      "soldiers": 35,
      "court_officials": 5,
      "emperor": 0,
      "monks": 5,
      "sages": 5,
      "swordsmen": 0
    },
    "walkSpeedMin": 0.6,
    "walkSpeedMax": 1.0,
    "walkRatio": 0.65,
    "pace": 0.5,
    "crowdRate": 30,
    "scaleVariation": 0.06,
    "boundaryShape": "circle",
    "boundaryRadius": 80,
    "spawnRadius": 80,
    "splinePoints": null,
    "facing": 0,
    "distribution": "random",
    "terrain": { "active": false, "amplitude": 2.0, "frequency": 0.08, "noiseType": "fractal" },
    "hueShift": 0.0,
    "hueVar": 0.035,
    "satVar": 0.18,
    "lightVar": 0.12,
    "collisionRadius": 0.4,
    "personalSpace": 0.8,
    "anticipation": 0.3,
    "obstacles": [
      { "type": "rock", "x": 20, "z": 15, "radius": 3.5 },
      { "type": "rock", "x": -25, "z": 30, "radius": 2.8 }
    ],
    "groupingMode": false,
    "attractActive": false,
    "attractTarget": { "x": 0, "z": 0 },
    "textures": true,
    "collisionEnabled": true,
    "jogBlendEnabled": true,
    "lazyGPU": false,
    "collection": "genji-v1"
  },
  "characters": [
    {
      "index": 0,
      "bucket": "soldiers",
      "body_id": "M_Terracotta_Infantry_Soldier",
      "face_id": "M_Chinese_Face_02",
      "hair_id": "Ming_Warrior_Crown",
      "x": 14.2317,
      "z": -8.5901,
      "rot_y": 3.9821,
      "scale": 1.0184,
      "phase": 0.7341,
      "anim_clip": "walk",
      "idle_variant": 5,
      "garment_hsl": [0.0181, 0.5442, 0.4716],
      "skin_hex": "#e8c8a0",
      "hair_hex": "#1a1410"
    },
    {
      "index": 1,
      "bucket": "laborers",
      "body_id": "Conscript_Laborer_Plain_Belted_Tunic",
      "face_id": "M_Chinese_Face_04",
      "hair_id": "Bamboo_Hat",
      "x": -22.1083,
      "z": 11.4452,
      "rot_y": 1.2076,
      "scale": 0.9812,
      "phase": 0.3295,
      "anim_clip": "idle",
      "idle_variant": 2,
      "garment_hsl": [0.0049, 0.5214, 0.5123],
      "skin_hex": "#ddb892",
      "hair_hex": "#251c14"
    }
    // … 148 个角色
  ],
  "shared_parts": ["Eye_L", "Eye_R", "L_Hand", "R_Hand", "L_Foot", "R_Foot"],
  // 资源 URL 反映服务器当前运行位置——请从响应中读取
  "assets": {
    "skeleton_glb":    "https://genji-api.ngrok.app/assets/Basemesh31.glb",
    "lod_glb":         "https://genji-api.ngrok.app/assets/Basemesh31_LOD1.glb",
    "capabilities_json": "https://genji-api.ngrok.app/v1/capabilities",
    "animations": {
      "idle":              "https://genji-api.ngrok.app/assets/A_Idle.glb",
      "idle_arms_crossed": "https://genji-api.ngrok.app/assets/A_Idle_ArmsCrossed.glb",
      "idle_hands_hip":    "https://genji-api.ngrok.app/assets/A_Idle_HandsHip.glb",
      "idle_clasp":        "https://genji-api.ngrok.app/assets/A_Idle_Clasp.glb",
      "idle_shift":        "https://genji-api.ngrok.app/assets/A_Idle_Shift.glb",
      "idle_look_around":  "https://genji-api.ngrok.app/assets/A_Idle_LookAround.glb",
      "idle_scratch":      "https://genji-api.ngrok.app/assets/A_Idle_Scratch.glb",
      "idle_tired":        "https://genji-api.ngrok.app/assets/A_Idle_Tired.glb",
      "walk":              "https://genji-api.ngrok.app/assets/A_WalkFwd.glb",
      "jog":               "https://genji-api.ngrok.app/assets/A_JogFwd.glb",
      "walk_stop":         "https://genji-api.ngrok.app/assets/A_WalkFwd_Stop.glb"
    }
  },
  "updated_at": "2026-06-24 10:00:00"
}

如何在引擎中渲染

1.  GET /v1/crowd/{crowd_id}                  → 获取响应 R

2.  下载一次 R.assets.skeleton_glb。
    其中包含每个部件对应的 SkinnedMesh 节点,所有节点绑定到同一共享骨架。
    自检:加载后的角色身高约 1.7 米。请勿做任何单位换算(见上方"坐标系")。

3.  对 R.characters[] 中的每个角色 C:

    a.  使用支持骨架的克隆方法克隆整个已加载场景
        (three.js:THREE.SkeletonUtils.clone(gltf.scene))。
        普通的 .clone(true) 会让克隆体仍绑定到原始骨架 ——
        所有副本将同步变形或渲染在错误位置。

    b.  选择网格节点:C.body_id、C.face_id、C.hair_id
        + R.shared_parts 中的所有节点
        隐藏所有其他 SkinnedMesh 节点。

    c.  放置角色 —— 变换只应用在克隆的最外层包装节点上:
        position  = (C.x, 0, C.z)   ← Y 取 (x,z) 处的地形高度
        rotation  = Quaternion.fromAxisAngle(Y, C.rot_y)
        scale     = Vector3(C.scale, C.scale, C.scale)   ← 仅为体型差异,约 0.95–1.05

        注意:蒙皮网格跟随的是骨骼,不是自身节点变换。给单个 SkinnedMesh
        设置 position/scale 不会有任何效果。请变换克隆调用返回的场景包装
        节点,并且绝不要碰内部名为 "root" 的节点 —— 它携带烘焙的
        0.01 厘米→米缩放,覆盖它会破坏角色。

    d.  应用颜色(先为每个角色克隆材质,避免颜色互相串扰):
        身体网格  → C.garment_hsl 是完整颜色:
                    material.color.setHSL(h, s, l)。无需查询基础色调。
        面部 + 手 + 脚 → C.skin_hex
        发型网格  → C.hair_hex

    e.  选择动画:
        if C.anim_clip == "idle":
            clip = idle_animation[C.idle_variant]   ← 见动画表
        else:
            clip = walk_animation
        动画片段以 R.assets.rest_reference_glb 为基准制作 ——
        播放前需重定向到骨架(见"动画片段")。
        startTime = C.phase × clip.duration

    f.  运行模拟:
        使用 R.recipe 作为智能体系统的配置输入:
          - boundary shape/radius  → 限制区域
          - collisionRadius        → NPC 体积大小(用于避让)
          - personalSpace          → 向心力 / 路径跟随强度
          - anticipation           → 提前转向距离
          - obstacles[]            → 静态避让圆盘
          - pace                   → 行走与待机角色比例
          - walkSpeedMin/Max       → 每 NPC 速度范围(× 片段速度)

常见集成故障(调试前先查此表)

症状 原因 解决方法
角色缩小约 100 倍 / 不可见 额外应用了 0.01 单位换算 移除 —— GLB 已是米制
角色放大约 100 倍 导入器丢弃了 GLB 根节点缩放 在根节点应用一次 0.01
角色沉入地面 / 设置位置无效 变换设置在了 SkinnedMesh 节点上 改为变换克隆的包装节点
所有克隆同步变形 / 集中在原点 使用了普通 .clone(true) 改用 SkeletonUtils.clone
身体与帽子/头发比例不一致 内部 "root" 节点的缩放被覆盖 绝不修改内部 "root" 节点
只有头部/帽子可见,没有身体 同上,或身体节点被误隐藏 检查 "root" 节点缩放和可见性列表
所有服装颜色相同 对共享材质实例着色 先为每个角色克隆材质再 setHSL
服装颜色不对 把 garment_hsl 当作基础色调的偏移量 它是完整颜色 —— 直接应用
角色悬空或下沉 为掩盖缩放问题添加了 Y 偏移 移除偏移,修复根节点变换

数据量规模

v1 响应包含完整的 characters[] 数组,每个 NPC 对应一个条目。数据量随人群规模线性增长:

recipe.size 近似响应大小
150(示例) ~55 KB
1 000 ~360 KB
5 000 ~1.8 MB
10 000 ~3.6 MB

对于数千角色以内的人群,在局域网或快速连接上表现良好。对于超大人群(10,000+),首次加载和解析完整列表可能增加明显延迟。

确定性

相同的 seed 和相同的请求参数始终返回相同的人群——相同的角色、位置和颜色,顺序也相同。您可以据此进行缓存和复现:按 crowd_id + seed(+ size)缓存响应并随意重新请求。生成在服务端运行;您只需消费已解析的 characters[] 数组并直接渲染。


认证(Authentication)

每个 /v1 请求都需要在 Bearer 令牌中提供您的 API 密钥:

Authorization: Bearer YOUR_API_KEY

请在 Genji 账户 → 密钥(Account → Keys) 页面创建和管理密钥。请妥善保密 —— 切勿提交到代码库或嵌入客户端代码中。


本地运行

# 1. 启动 API 服务器
npm run server

# 2. 写入示例数据(运行一次)
node --env-file=.env server/seed-api.js

# 3. 测试
curl -s \
  -H "Authorization: Bearer YOUR_API_KEY" \
  https://genji-api.ngrok.app/v1/crowd/cr_sample01

通过 ngrok 对外暴露以供远程测试(请勿自动运行):

ngrok http 3001
# 当前隧道:https://genji-api.ngrok.app
# 将 https://genji-api.ngrok.app/v1/crowd/cr_sample01 分享给集成团队。
# 启动服务器前设置 ASSET_BASE_URL=https://genji-api.ngrok.app。

Unity / Tuanjie 集成

控制分为两个层级。

第一层:设计阶段(加载前)

人群在 Genji 编辑器中创作并保存为配方(recipe)。工程团队调用 API,获取配方和角色列表,然后在 Unity / Tuanjie 中构建人群。如需修改形状、颜色、行走比例或障碍物,只需在 Genji 中编辑配方后重新请求即可。

角色克隆:使用 SkeletonUtils,而非 scene.clone()

所有角色共享同一个 GLB 文件。要在场景中放置多个角色,必须为每个角色单独克隆已加载的场景图。请勿使用 scene.clone(true) — 该方法虽然复制了节点,但克隆出的 SkinnedMesh 实例仍绑定到原始骨架的骨骼上,导致所有副本同步变形或完全不可见。

请使用 THREE.SkeletonUtils.clone()(Three.js)或引擎对应的深度克隆方法,确保每个克隆独立绑定骨架:

import { clone } from 'three/examples/jsm/utils/SkeletonUtils.js';

// gltf = GLTFLoader.load(assets.skeleton_glb, ...) 的加载结果
for (const C of characters) {
  const root = clone(gltf.scene);   // 正确用法 — 每个克隆独立绑定骨骼
  // 隐藏所有网格,仅显示 C.body_id、C.face_id、C.hair_id 及 shared_parts
  root.position.set(C.x, 0, C.z);
  scene.add(root);
}

第二层:运行阶段(加载后)

API 提供的是初始状态。运行时,您的 Unity / Tuanjie 代码驱动整个模拟。配方字段作为配置参数输入到您自己的智能体系统中:

字段 控制内容
boundaryShape + boundaryRadius 将角色限制在此区域内
pace 行走与待机角色的比例
walkSpeedMin / walkSpeedMax 每个角色的速度范围
collisionRadius 角色间最小距离
personalSpace 拥挤时的排斥力强度
anticipation 提前转向的距离
obstacles[] 需要绕行的静态障碍物
attractTarget 角色聚集的目标点

您的引擎拥有完整的运行时控制权——可以逐帧覆盖上述任意参数、移动吸引点、动态添加障碍物或实时调整行走比例。API 提供的是初始设计值,运行时模拟完全由您的引擎驱动。

未来规划功能

  1. 运行时 API — 实时连接,支持在场景进行中更新人群配置,无需重新加载。
  2. 编辑器实时联动 — Genji 编辑器直接将配方变更推送到运行中的 Unity / Tuanjie 实例。
  3. 单角色覆盖 — 无需重新加载整个人群,即可替换单个角色的服装或位置。

当前模式:一次加载,自主模拟。