From c65daede2e94bb512efa254b262bc577c168fef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Sun, 23 Aug 2026 14:24:11 +0800 Subject: [PATCH] feat(animation): script-playable skeletal animation via AnimSkin writeback AnimSkin can now write CPU-skinning results back to the render mesh so skeletal animation actually plays from scripts: - capture bind normals at fromModel and skin them per pose (updateSkinnedNormals / getSkinnedNormals, cofactor inverse-transpose + normalize) - packed getSkinnedPositions() array getter, matching AnimLattice's script pattern - one-call applyToMesh(gfx, mesh, pose): skin positions+normals then in-place gpu.updateMeshVertices - Renderable3D.getMesh() script binding so createRenderable meshes are reachable - Mesh::gpuVertexCount so getVertexCount() is correct for non-morph meshes (both backends) - tests: packed positions == skinPositionsTo, normals follow pose and stay unit length, applyToMesh render test with in-place updates - docs: animation/graphics user guides + design spec --- ...8-23-animskin-script-skinning-writeback.md | 204 ++++++++++++++++ docs/usr/modules/animation.md | 14 +- docs/usr/modules/graphics.md | 6 +- src/modules/animation/AnimSkin.cpp | 115 +++++++++ src/modules/animation/AnimSkin.h | 38 +++ src/modules/animation/Animation.cpp | 10 + src/modules/graphics/Graphics.cpp | 1 + src/modules/graphics/Mesh.cpp | 5 +- src/modules/graphics/Mesh.h | 2 + src/modules/graphics/RenderSystem3D.cpp | 2 + src/modules/graphics/RenderSystem3D.h | 2 + src/modules/graphics/vulkan/Graphics.h | 1 + .../graphics/vulkan/GraphicsInternal.h | 4 + src/modules/graphics/vulkan/GraphicsMesh.cpp | 1 + src/modules/graphics/webgpu/Graphics.cpp | 1 + test/animation_skinned.cpp | 228 ++++++++++++++++++ 16 files changed, 628 insertions(+), 6 deletions(-) create mode 100644 docs/dev/superpowers/specs/2026-08-23-animskin-script-skinning-writeback.md diff --git a/docs/dev/superpowers/specs/2026-08-23-animskin-script-skinning-writeback.md b/docs/dev/superpowers/specs/2026-08-23-animskin-script-skinning-writeback.md new file mode 100644 index 00000000..8facf820 --- /dev/null +++ b/docs/dev/superpowers/specs/2026-08-23-animskin-script-skinning-writeback.md @@ -0,0 +1,204 @@ +# 骨骼动画脚本播放:AnimSkin 蒙皮结果写回渲染网格 API 设计 + +日期:2026-08-23 + +## 背景 + +有 agent 报告:引擎脚本层 CPU 蒙皮(`AnimSkin`)能算出骨骼位置,但没有接口把 +蒙皮结果写回渲染网格,因此骨骼动画在脚本里没法真正播放。 + +## 问题确认(现状调研) + +结论:报告的核心结论**成立**——脚本侧目前端到端确实播放不了骨骼动画;但“没有 +任何接口”的说法不准确,仓库里已存在通用回写原语,缺的是 `AnimSkin` 侧的便捷 +接口、法线蒙皮和几条配套脚本通路。 + +逐项证据: + +| 说法 | 实际情况 | +| --- | --- | +| `AnimSkin` 能算出骨骼顶点位置 | 成立。`AnimSkin::skinPositions` / `updateSkinnedPositions` 做 CPU 线性混合蒙皮(`src/modules/animation/AnimSkin.h/.cpp`)。 | +| 没有接口写回渲染网格 | 不准确。`Graphics::updateMeshVertices` 已实现并绑定脚本(`src/modules/graphics/Graphics.cpp:728`,Vulkan 实现 `GraphicsMesh.cpp:282`)。 | +| 脚本里仍无法播放骨骼动画 | 成立,原因如下面 4 点。 | + +真正缺的四点: + +1. **`AnimSkin` 没有打包数组 getter。** 脚本绑定只有逐分量 + `getSkinnedPositionX/Y/Z(vertexIndex)`(`Animation.cpp:443-447`),没有像 + `AnimLattice::getDeformedPositions()` 那样返回 `xyz` 打包数组的方法 + (`AnimLattice.h:168`)。脚本要回写就得每帧 `3N` 次脚本调用组数组 + (CesiumMan 约 2k+ 顶点,即 6k+ 次/帧),不现实。 +2. **完全没有法线蒙皮。** `AnimSkin` 只算位置,回写后法线仍是 bind pose, + 肢体旋转时光照错误。现有 C++ 渲染测试也是把静态 bind 法线原样上传 + (`test/animation_skinned.cpp` 的 `renderSkinnedAnimation`),C++ 侧同样不完整。 +3. **脚本拿不到网格句柄。** `model3d.createRenderable(gfx, model, meshIndex)` + 内部用 `newMeshFromAssimp` 建网格并 `setMesh`(`ModelRenderer.cpp:133-142`), + 但 `Renderable3D` 没有绑定 `getMesh()`(只有 `setMesh` / `getPartMesh`); + `ModelData` 也没有暴露位置/法线/索引数组 getter,用户无法自行建蒙皮网格。 +4. **文档/示例缺失。** `docs/usr/modules/animation.md` 的 CPU 蒙皮示例只写到 + “脚本可读 `getSkinnedPositionX/Y/Z(i)`”并转给粒子;`graphics.md` 快查甚至 + 未收录 `updateMeshVertices`;没有任一个 `.nut` 示例播放蒙皮网格。 + +附带坑:`Mesh::getVertexCount()` 返回 morph CPU 基数 +`basePos_.size()/3`(`Mesh.cpp:103`),对无 morph 的网格(`newMeshFromArrays` / +普通 `newMeshFromAssimp`)恒为 0,脚本用户不能拿它当回写长度。 + +参考的既有模式:`AnimLattice` 已经走通“脚本算好变形 → `gfx.updateMeshVertices` +回传 GPU”的完整链路(`AnimLattice.h:34-36`、`examples/lattice-deform/main.nut:175`), +`AnimSkin` 直接沿用即可。 + +## 目标 + +- 让脚本一行调用完成“播放器推进 → 姿态世界矩阵 → CPU 蒙皮 → 写回网格”。 +- 法线一并蒙皮,保证旋转/缩放后光照正确。 +- 与 `AnimLattice` 既有 API 风格保持一致,同时保留逐分量 getter 兼容。 + +## 非目标 + +- 不做 GPU 骨骼动画(bone matrix UBO / 蒙皮 shader),本轮保持 CPU 蒙皮。 +- 不改 `AnimSkeleton` / `AnimClip` / `AnimPlayer` / `AnimPose` 行为。 +- 不引入新第三方依赖(法线变换用自带的 3x3 逆矩阵辅助函数)。 + +## 放置位置 + +全部改动在 `animation` 模块(与 `AnimSkin` 同层): + +- `src/modules/animation/AnimSkin.h/.cpp`:新增方法;头文件只前向声明 + `eve::graphics::Graphics` / `eve::graphics::Mesh`,`.cpp` 内 + `#include "graphics/Graphics.h"`(`animation` 已依赖 `graphics`, + `AnimTrail.cpp` / `SpineAnim.cpp` 已有同样 include,depgraph 无头文件泄漏)。 +- `src/modules/animation/Animation.cpp`:新增脚本绑定。 +- `src/modules/graphics/RenderSystem3D.h` 与 `Graphics.cpp`:给 `Renderable3D` + 补 `getMesh()` 绑定(配套,让脚本拿到 `createRenderable` 建的网格)。 +- 可选小修:`Mesh` 增加 GPU 顶点数记录,修 `getVertexCount()` 对无 morph 网格 + 返回 0 的问题。 + +## API(脚本) + +### 1. 一行播放(推荐) + +```squirrel +skin.applyToMesh(gfx, mesh, pose); // 位置 + 法线一次完成,返回 bool +``` + +等价于:`updateSkinnedPositions(pose)` → (内部)bind 法线蒙皮 → +`gfx.updateMeshVertices(mesh, pos, nrm, [], vertexCount, [], 0)`。 +返回 `false` 表示参数非法或后端不支持(WebGPU `updateMeshVertices` 返回 false)。 + +### 2. 底层原语(与 `AnimLattice` 对齐,供自定义流水线) + +```squirrel +skin.updateSkinnedPositions(pose); +skin.updateSkinnedNormals(pose); // 用 fromModel 时拷贝的内部 bind 法线 +local pos = skin.getSkinnedPositions(); // [x0,y0,z0,x1,...],先蒙皮否则空数组 +local nrm = skin.getSkinnedNormals(); +gfx.updateMeshVertices(mesh, pos, nrm, [], skin.getVertexCount(), [], 0); +``` + +### 3. 配套 + +```squirrel +local ent = model3d.createRenderable(gfx, model, 0); +local mesh = ent.getMesh(); // 新增绑定 +``` + +## C++ 侧关键方法 + +`AnimSkin.h` 新增: + +```cpp +/** @brief Packed skinned positions (xyz, vertexCount*3). Empty if not skinned yet. */ +std::vector getSkinnedPositions() const; + +/** + * @brief Skin bind normals (captured at fromModel) with the given pose. + * pose must already have computeWorld(skeleton) applied. + */ +bool updateSkinnedNormals(const AnimPose *pose); + +/** @brief Packed skinned normals (xyz, vertexCount*3). Empty if not skinned yet. */ +std::vector getSkinnedNormals() const; + +/** + * @brief Skin positions (+ normals) and write back to mesh in one call. + * pose must already have computeWorld(skeleton). Returns false when the + * backend cannot update in place (WebGPU) or arguments are invalid. + */ +bool applyToMesh(graphics::Graphics *gfx, graphics::Mesh *mesh, + const AnimPose *pose); +``` + +实现要点: + +- `fromModel` 额外把 `aiMesh->mNormals`(若有)拷贝为 `bindNrm_`,与 `bindPos_` + 并列;没有法线时 `updateSkinnedNormals` 返回 false。 +- 法线蒙皮复用 `skinPositions` 的 skin matrix + `M_j = boneWorld_j * inverseBind_j`,对 `M_j` 的 3x3 线性部分取逆转置并 + normalize(`n' = normalize(IT(M33) * n)`)。`AnimMath::Mat4` 没有求逆, + 在 `AnimSkin.cpp` 内加一个小型 3x3 adjugate 辅助函数,不引入依赖。 +- `applyToMesh` 内部:`skinPositions(pose, pos)` → 可选 `updateSkinnedNormals(pose)` + → `gfx->updateMeshVertices(mesh, pos.data(), nrmPtr, nullptr, vertexCount_, + nullptr, 0)`;`updateMeshVertices` 会顺带重算包围球(`GraphicsMesh.cpp:303`)。 +- 绑定包装(`Animation.cpp`):`getSkinnedPositions` / `getSkinnedNormals` + 直接绑返回 `std::vector`(与 `AnimLattice` 相同);`applyToMesh` 用 + `std::function` 包装。 + +配套小修(`Mesh`): + +```cpp +int gpuVertexCount = 0; // 上传时填写 +int getVertexCount() const { return !basePos_.empty() ? int(basePos_.size() / 3) + : gpuVertexCount; } +``` + +在 `newMeshFromAssimp` / `newMeshFromArrays` 上传路径里填 `gpuVertexCount`, +同时让 `applyToMesh` 可以校验 `mesh->getVertexCount() == vertexCount_`。 + +## 示例(脚本) + +```squirrel +local model = model3d.newModelDataFromFile("CesiumMan.gltf"); +local ent = model3d.createRenderable(gfx, model, 0); // meshIndex 需 hasBones +local mesh = ent.getMesh(); +local sk = anim.newSkeletonFromModel(model); +local clip = anim.newClipFromModel(model, sk, 0); +local skin = anim.newSkinFromModel(model, 0, sk); +local player = anim.newPlayer(sk); +player.play(clip); + +eve_update = function(dt) { + player.update(dt); + local pose = player.getPose(); + pose.computeWorld(sk); + skin.applyToMesh(gfx, mesh, pose); // 每帧一行 +}; +``` + +注意:蒙皮网格必须与 `AnimSkin` 同源(同一 `ModelData` / 同一 meshIndex),且 +上传时不要烘焙节点世界变换(`newMeshFromAssimp` 的 worldTransform 重载),否则 +回写的模型空间位置与烘焙后的网格对不上。 + +## 测试 + +- C++ 单测(`test/animation_skinned.cpp` 追加): + - `animation.skinned.getSkinnedPositionsMatchesSkinPositionsTo`: + `updateSkinnedPositions` 后 `getSkinnedPositions` 与 `skinPositionsTo` 输出一致。 + - `animation.skinned.normalsFollowPose`:bind pose 下法线不变(skin matrix + 近似单位阵),mid-clip 姿态下至少一个法线发生旋转且全部保持单位长度。 + - `animation.skinned.render.applyToMesh`:网格只创建一次,每帧 + `applyToMesh` 原地写回(不再每帧 `newMeshFromArrays` 重建),断言 + 多帧 luma 有变化、末帧前景像素数达标。 +- WebGPU 目标:断言 `applyToMesh` 返回 false(后端暂不支持原地更新)。 +- 脚本冒烟:新增 `examples/skinned-anim/`(可选)或按 `lattice-deform` 的 + 结构补一个最小示例。 + +## 兼容性与格式 + +- 纯新增 API,不改现有方法签名与行为;逐分量 getter 保留。 +- 文档同步: + - `docs/usr/modules/animation.md`:CPU 蒙皮示例改为 `applyToMesh` 一行, + 快查表补 `getSkinnedPositions` / `updateSkinnedNormals` / `getSkinnedNormals` + / `applyToMesh`。 + - `docs/usr/modules/graphics.md`:快查表补 `updateMeshVertices` / `getMesh`。 +- 提交前 `git clang-format` 只格式化改动行;按模块约定单 PR 提交接口 + 后端 + + 消费者(本例消费者为脚本示例与文档)。 diff --git a/docs/usr/modules/animation.md b/docs/usr/modules/animation.md index 9706069f..076a98ba 100644 --- a/docs/usr/modules/animation.md +++ b/docs/usr/modules/animation.md @@ -196,12 +196,22 @@ player.play(clip); player.update(dt); local pose = player.getPose(); pose.computeWorld(sk); -skin.updateSkinnedPositions(pose); // 脚本可读 getSkinnedPositionX/Y/Z(i) +// 推荐:蒙皮(位置+法线)并原地写回渲染网格,每帧一行: +skin.applyToMesh(gfx, mesh, pose); // mesh 与 skin 需同源(同一 ModelData/meshIndex) +// 也可拆开用底层原语(与 AnimLattice 一致): +// skin.updateSkinnedPositions(pose); // 脚本可读 getSkinnedPositionX/Y/Z(i) +// skin.updateSkinnedNormals(pose); // 用 fromModel 捕获的 bind 法线 +// gfx.updateMeshVertices(mesh, skin.getSkinnedPositions(), skin.getSkinnedNormals(), +// [], skin.getVertexCount(), [], 0); // 也可交给粒子:emitter.setSkinSource(skin, pose) 从皮肤表面发射 ``` 测试资源:`scripts/download_skinned_character.sh` 下载 Khronos **CesiumMan**(约 0.5 MB)到 `test/assets/skinned/`;CMake 选项 `EVENGINE_DOWNLOAD_SKINNED_CHARACTER`(默认 ON)会在构建 `unit_test` 时联网拉取。 +`model3d.createRenderable(gfx, model, meshIndex)` 建的网格可用 +`ent.getMesh()` 取回句柄交给 `applyToMesh`。蒙皮网格必须与 `AnimSkin` 同源, +且上传时不要烘焙节点世界变换,否则回写的模型空间顶点会与网格对不上。 + ## 对象关系与调用时机 - `Animation` 拥有 Tween / SpriteAnim / SpineAnim 注册表并统一 `update`;3D 对象与 `AnimTrail` 由脚本持有,各自 `update(dt)`。 @@ -279,7 +289,7 @@ skin.updateSkinnedPositions(pose); // 脚本可读 getSkinnedPositionX/Y/Z(i) - `AnimSkeleton`:`addBone()`、`getBoneCount()`、`getBoneName()`、`findBone()`、`getParent()`、`setBindPosition()`、`setBindRotation()`、`setBindScale()`、`getBind*()`、`applyBindPose()` - `AnimClip`:`setName()`、`getName()`、`setDuration()`、`getDuration()`、`setLoop()`、`getLoop()`、`setSampleRate()`、`addPositionKey()`、`addRotationKey()`、`addScaleKey()`、`sample()`、`wrapTime()` - `AnimPose`:`resize()`、`copyFrom()`、`blendFrom()`、`setLocal*()`、`getLocal*()`、`computeWorld()`、`getWorld*()`、`getWorldMatrixElement()` -- `AnimSkin`:`getVertexCount()`、`getBoneCount()`、`getSkeletonBone()`、`getSkinBoneName()`、`getInverseBindElement()`、`getBindPosition*()`、`getVertexBone()`、`getVertexWeight()`、`updateSkinnedPositions()`、`hasSkinnedPositions()`、`getSkinnedPosition*()` +- `AnimSkin`:`getVertexCount()`、`getBoneCount()`、`getSkeletonBone()`、`getSkinBoneName()`、`getInverseBindElement()`、`getBindPosition*()`、`getVertexBone()`、`getVertexWeight()`、`updateSkinnedPositions()`、`hasSkinnedPositions()`、`getSkinnedPosition*()`、`getSkinnedPositions()`、`updateSkinnedNormals()`、`hasSkinnedNormals()`、`getSkinnedNormals()`、`applyToMesh()` - `AnimPlayer`:`play()`、`crossFade()`、`stop()`、`pause()`、`resume()`、`setSpeed()`、`setTime()`、`setLoop()`、`getPose()`、`update()` - `AnimStateMachine`:`addState()`、`setEntry()`、`addTransition()`、`addFloatCondition()`、`addBoolCondition()`、`addTriggerCondition()`、`setExitTime()`、`setFloat()`、`setBool()`、`setTrigger()`、`getPose()`、`update()` - `MotionDatabase`:`addFeatureBone()`、`addFeatureBoneByName()`、`addClip()`、`bake()`、`getFrameCount()`、`getFeatureSize()` diff --git a/docs/usr/modules/graphics.md b/docs/usr/modules/graphics.md index e9b43868..d9abf668 100644 --- a/docs/usr/modules/graphics.md +++ b/docs/usr/modules/graphics.md @@ -180,16 +180,16 @@ rc.disable("msaa"); // 或通过 RenderControl 特性关 下列方法名来自当前 Squirrel 绑定;同一模块创建的辅助对象(例如 `World`、`Body`、`Source`)的方法也列在这里。 -- `bakeMeshMorph()`、`clear()`、`clearMorphWeights()`、`declareFloat()`、`declareMatrix()`、`declareVec2()`、`declareVec3()`、`declareVec4()` +- `bakeMeshMorph()`、`newMeshFromArrays()`、`updateMeshVertices()`、`clear()`、`clearMorphWeights()`、`declareFloat()`、`declareMatrix()`、`declareVec2()`、`declareVec3()`、`declareVec4()` - `drawSolidRect()`、`drawTexturedRect()`、`drawOcclusionSolid()`、`drawOcclusionTexture()`、`getCastShadow()`、`getCastOcclusion()`、`getDirX()`、`getDirY()`、`getDirZ()`、`getHeight()`、`getMorphCount()` - `getMorphName()`、`getMorphWeight()`、`getName()`、`getRadius()`、`getScreenRayDirX()`、`getScreenRayDirY()`、`getScreenRayDirZ()`、`getScreenRayOriginX()` -- `getScreenRayOriginY()`、`getScreenRayOriginZ()`、`getShader()`、`getShadowBias()`、`getShadowStrength()`、`getType()`、`getUniformIndex()`、`getVertexCount()` +- `getScreenRayOriginY()`、`getScreenRayOriginZ()`、`getShader()`、`getShadowBias()`、`getShadowStrength()`、`getType()`、`getUniformIndex()`、`getVertexCount()`、`getIndexCount()` - `getVolumetric()`、`getVolumetricIntensity()`、`getWidth()`、`getX()`、`getY()`、`getYaw()`、`getZ()`、`getZoom()`、`hasMorph()`、`hasMorphData()` - `hasUniform()`、`isEnabled()`、`isMorphDirty()`、`newHairShader()`、`newMeshCylinder()`、`newMeshShader()`、`newMeshSphere()`、`newQuad()`、`newShader()` - `newShaderFromSpvFile()`、`newTexture()`、`newTextureWithSampler()`、`setTextureSampler()`、`getMaxAnisotropy()`、`newVolumetric()`、`newAmbientOcclusion()`、`newGlobalIllumination()`、`newAntiAliasing()`、`setMsaaSamples()`、`getMsaaSamples()`、`present()`、`render3D()`、`reset()`、`screenToRay()`、`screenToWorldX()`、`screenToWorldY()` - `sendFloat()`、`sendVec2()`、`sendVec3()`、`sendVec4()`、`setActive()`、`setAmbient()`、`setBackgroundColor()`、`setCamera()` - `setCanvas()`、`setCastOcclusion()`、`setCastShadow()`、`setColor()`、`setDirection()`、`setDirectionalLight()`、`setEnabled()`、`setEnvIntensity()`、`setEnvMap()` -- `setEye()`、`setFov()`、`setMesh()`、`setMeshLod()`、`clearMeshLod()`、`getMeshLodCount()`、`getMeshLodLevelAtDistance()`、`setMetallic()`、`setMorphWeight()`、`setNormalTexture()`、`setHeightTexture()`、`setPosition()`、`setRadius()` +- `setEye()`、`setFov()`、`setMesh()`、`getMesh()`、`setMeshLod()`、`clearMeshLod()`、`getMeshLodCount()`、`getMeshLodLevelAtDistance()`、`setMetallic()`、`setMorphWeight()`、`setNormalTexture()`、`setHeightTexture()`、`setPosition()`、`setRadius()` - `setReceiveLight()`、`setReceiveShadow()`、`setRotation()`、`setRoughness()`、`setScale()`、`setShader()`、`setHair()`、`getHair()`、`setShadowBias()`、`setShadowStrength()` - `setTarget()`、`setTexCellBomb()`、`getTexCellBombScale()`、`getTexCellBombStrength()`、`getTexCellBombRotation()`、`setParallax()`、`getParallaxScale()`、`getParallaxMinLayers()`、`getParallaxMaxLayers()`、`setTexture()`、`setTint()`、`setType()`、`setUp()`、`setViewport()`、`setVisible()`、`setVolumetric()`、`setVolumetricIntensity()`、`setYaw()` - `setZoom()`、`worldToScreenX()`、`worldToScreenY()`、`Texture.getMipmapCount()` diff --git a/src/modules/animation/AnimSkin.cpp b/src/modules/animation/AnimSkin.cpp index b90e3615..5fb0cf2c 100644 --- a/src/modules/animation/AnimSkin.cpp +++ b/src/modules/animation/AnimSkin.cpp @@ -3,6 +3,8 @@ #include "animation/AnimSkeleton.h" #include "common/Exception.h" +#include "graphics/Graphics.h" +#include "graphics/Mesh.h" #include "model3d/ModelData.h" #include @@ -41,6 +43,27 @@ Mat4 fromAiMatrix(const aiMatrix4x4 &m) { return out; } +/** + * Cofactor matrix (adjugate-transpose) of the upper 3x3 of a column-major Mat4. + * (M^{-1})^T = cof(M) / det(M), and the scalar 1/det cancels under + * normalization, so normals transform as n' = normalize(cof(M) * n). + */ +void normalMatrixCofactor(const Mat4 &m, float out[9]) { + // Row-major 3x3 linear part of the skin matrix. + const float r0x = m.m[0], r0y = m.m[4], r0z = m.m[8]; + const float r1x = m.m[1], r1y = m.m[5], r1z = m.m[9]; + const float r2x = m.m[2], r2y = m.m[6], r2z = m.m[10]; + out[0] = r1y * r2z - r1z * r2y; + out[1] = r1z * r2x - r1x * r2z; + out[2] = r1x * r2y - r1y * r2x; + out[3] = r2y * r0z - r2z * r0y; + out[4] = r2z * r0x - r2x * r0z; + out[5] = r2x * r0y - r2y * r0x; + out[6] = r0y * r1z - r0z * r1y; + out[7] = r0z * r1x - r0x * r1z; + out[8] = r0x * r1y - r0y * r1x; +} + } // namespace void AnimSkin::requireVertex(int vertexIndex) const { @@ -80,6 +103,15 @@ AnimSkin *AnimSkin::fromModel(const model3d::ModelData *model, int meshIndex, skin->bindPos_[static_cast(v) * 3u + 1] = p.y; skin->bindPos_[static_cast(v) * 3u + 2] = p.z; } + if (mesh->HasNormals() && mesh->mNormals) { + skin->bindNrm_.resize(static_cast(skin->vertexCount_) * 3u); + for (int v = 0; v < skin->vertexCount_; ++v) { + const aiVector3D &n = mesh->mNormals[v]; + skin->bindNrm_[static_cast(v) * 3u + 0] = n.x; + skin->bindNrm_[static_cast(v) * 3u + 1] = n.y; + skin->bindNrm_[static_cast(v) * 3u + 2] = n.z; + } + } // Gather skin joints that map onto the skeleton by name. std::vector meshBoneToSkin(mesh->mNumBones, -1); @@ -279,4 +311,87 @@ float AnimSkin::getSkinnedPositionZ(int vertexIndex) const { return skinnedPos_[static_cast(vertexIndex) * 3u + 2]; } +std::vector AnimSkin::getSkinnedPositions() const { + if (!skinnedValid_) return {}; + return skinnedPos_; +} + +bool AnimSkin::updateSkinnedNormals(const AnimPose *pose) { + skinnedNrmValid_ = false; + if (!pose || bindNrm_.empty() || vertexCount_ <= 0) return false; + + // n' = sum_i w_i * (M_i^{-1})^T * n, M_i = boneWorld * inverseBind. + std::vector nrmMats(inverseBind_.size() * 9u); + for (size_t j = 0; j < inverseBind_.size(); ++j) { + const int skelBone = skeletonBone_[j]; + const Mat4 world = Mat4::fromTRS(pose->world(skelBone)); + const Mat4 skinMat = Mat4::mul(world, inverseBind_[j]); + normalMatrixCofactor(skinMat, nrmMats.data() + j * 9u); + } + + skinnedNrm_.resize(static_cast(vertexCount_) * 3u); + for (int v = 0; v < vertexCount_; ++v) { + const size_t base = static_cast(v) * 3u; + const float bx = bindNrm_[base + 0]; + const float by = bindNrm_[base + 1]; + const float bz = bindNrm_[base + 2]; + float nx = 0.f, ny = 0.f, nz = 0.f; + float wsum = 0.f; + for (int i = 0; i < kMaxInfluences; ++i) { + const Influence &inf = + influences_[static_cast(v) * kMaxInfluences + i]; + if (inf.bone < 0 || inf.weight <= 0.f) continue; + const float *c = nrmMats.data() + static_cast(inf.bone) * 9u; + const float tx = c[0] * bx + c[1] * by + c[2] * bz; + const float ty = c[3] * bx + c[4] * by + c[5] * bz; + const float tz = c[6] * bx + c[7] * by + c[8] * bz; + nx += tx * inf.weight; + ny += ty * inf.weight; + nz += tz * inf.weight; + wsum += inf.weight; + } + if (wsum <= 1e-8f) { + nx = bx; + ny = by; + nz = bz; + } else { + const float len = std::sqrt(nx * nx + ny * ny + nz * nz); + if (len > 1e-8f) { + nx /= len; + ny /= len; + nz /= len; + } else { + nx = bx; + ny = by; + nz = bz; + } + } + skinnedNrm_[base + 0] = nx; + skinnedNrm_[base + 1] = ny; + skinnedNrm_[base + 2] = nz; + } + skinnedNrmValid_ = true; + return true; +} + +std::vector AnimSkin::getSkinnedNormals() const { + if (!skinnedNrmValid_) return {}; + return skinnedNrm_; +} + +bool AnimSkin::applyToMesh(graphics::Graphics *gfx, graphics::Mesh *mesh, + const AnimPose *pose) { + if (!gfx || !mesh || !mesh->gpuHandle || !pose || vertexCount_ <= 0) return false; + if (mesh->getVertexCount() != vertexCount_) return false; + + std::vector pos; + if (!skinPositionsTo(pose, pos)) return false; + + std::vector nrm; + if (updateSkinnedNormals(pose)) nrm = skinnedNrm_; + + return gfx->updateMeshVertices(mesh, pos.data(), nrm.empty() ? nullptr : nrm.data(), + nullptr, vertexCount_, nullptr, 0); +} + } // namespace eve::animation diff --git a/src/modules/animation/AnimSkin.h b/src/modules/animation/AnimSkin.h index 76cc2091..1e66c768 100644 --- a/src/modules/animation/AnimSkin.h +++ b/src/modules/animation/AnimSkin.h @@ -9,6 +9,11 @@ namespace eve::model3d { class ModelData; } +namespace eve::graphics { +class Graphics; +class Mesh; +} + namespace eve::animation { class AnimSkeleton; @@ -78,6 +83,36 @@ public: float getSkinnedPositionY(int vertexIndex) const; float getSkinnedPositionZ(int vertexIndex) const; + /** + * @brief Packed skinned positions (xyz, vertexCount*3) as a copy. + * Empty when updateSkinnedPositions() has not succeeded yet. + */ + std::vector getSkinnedPositions() const; + + /** + * @brief Skin the bind-pose normals captured at fromModel with the pose. + * pose must already have computeWorld(skeleton) applied and match the + * skeleton used at fromModel time. Returns false when the model mesh has + * no normals or pose is null. + */ + bool updateSkinnedNormals(const AnimPose *pose); + + /** @brief True after a successful updateSkinnedNormals(). */ + bool hasSkinnedNormals() const { return skinnedNrmValid_; } + + /** @brief Packed skinned normals (xyz, vertexCount*3) as a copy. */ + std::vector getSkinnedNormals() const; + + /** + * @brief Skin positions (and normals when available) and write the result + * back to a GPU mesh in one call. pose must already have computeWorld() + * applied. The mesh must be built from the same model/mesh this skin was + * created from, with matching vertex count. Returns false when the + * backend cannot update in place (e.g. WebGPU) or arguments are invalid. + */ + bool applyToMesh(graphics::Graphics *gfx, graphics::Mesh *mesh, + const AnimPose *pose); + /** @brief Bind-pose (unskinned) position component for vertex v (0..vertexCount-1). */ float getBindPositionX(int vertexIndex) const; float getBindPositionY(int vertexIndex) const; @@ -102,8 +137,11 @@ private: std::vector skeletonBone_; // skin joint → skeleton bone std::vector skinBoneNames_; std::vector inverseBind_; + std::vector bindNrm_; // xyz packed, may be empty std::vector skinnedPos_; // xyz packed cache + std::vector skinnedNrm_; // xyz packed cache bool skinnedValid_ = false; + bool skinnedNrmValid_ = false; }; } // namespace eve::animation diff --git a/src/modules/animation/Animation.cpp b/src/modules/animation/Animation.cpp index fee81c40..115899b6 100644 --- a/src/modules/animation/Animation.cpp +++ b/src/modules/animation/Animation.cpp @@ -22,6 +22,8 @@ #include "animation/SpineSkeletonData.h" #include "common/Exception.h" +#include "graphics/Graphics.h" +#include "graphics/Mesh.h" #include #include @@ -445,6 +447,14 @@ void Animation::expose(ssq::Table &table) { skin.addFunc("getSkinnedPositionX", &AnimSkin::getSkinnedPositionX); skin.addFunc("getSkinnedPositionY", &AnimSkin::getSkinnedPositionY); skin.addFunc("getSkinnedPositionZ", &AnimSkin::getSkinnedPositionZ); + skin.addFunc("getSkinnedPositions", &AnimSkin::getSkinnedPositions); + skin.addFunc("updateSkinnedNormals", &AnimSkin::updateSkinnedNormals); + skin.addFunc("hasSkinnedNormals", &AnimSkin::hasSkinnedNormals); + skin.addFunc("getSkinnedNormals", &AnimSkin::getSkinnedNormals); + skin.addFunc("applyToMesh", + std::function( + &AnimSkin::applyToMesh)); auto lattice = table.addClass( "AnimLattice", std::function([]() -> AnimLattice * { return nullptr; }), diff --git a/src/modules/graphics/Graphics.cpp b/src/modules/graphics/Graphics.cpp index 97de685b..ea7df9e6 100644 --- a/src/modules/graphics/Graphics.cpp +++ b/src/modules/graphics/Graphics.cpp @@ -355,6 +355,7 @@ void Graphics::expose(ssq::Table& table) { ent.addFunc("getYaw", &Renderable3D::getYaw); ent.addFunc("setScale", &Renderable3D::setScale); ent.addFunc("setMesh", &Renderable3D::setMesh); + ent.addFunc("getMesh", &Renderable3D::getMesh); ent.addFunc("setTexture", &Renderable3D::setTexture); ent.addFunc("setNormalTexture", &Renderable3D::setNormalTexture); ent.addFunc("setHeightTexture", &Renderable3D::setHeightTexture); diff --git a/src/modules/graphics/Mesh.cpp b/src/modules/graphics/Mesh.cpp index 84090b6e..28313033 100644 --- a/src/modules/graphics/Mesh.cpp +++ b/src/modules/graphics/Mesh.cpp @@ -100,7 +100,10 @@ bool Mesh::addMorphTargetAbsolute(const std::string &name, const float *absPosXY return true; } -int Mesh::getVertexCount() const { return int(basePos_.size() / 3); } +int Mesh::getVertexCount() const { + if (!basePos_.empty()) return int(basePos_.size() / 3); + return gpuVertexCount; +} int Mesh::getMorphCount() const { return int(morphs_.size()); } diff --git a/src/modules/graphics/Mesh.h b/src/modules/graphics/Mesh.h index 1969e59f..9176bc09 100644 --- a/src/modules/graphics/Mesh.h +++ b/src/modules/graphics/Mesh.h @@ -19,6 +19,8 @@ class Mesh : public Drawable { public: int indexCount = 0; void *gpuHandle = nullptr; // vulkan::GpuMesh* + /** @brief Vertex count of the GPU buffer (morph CPU base may be empty). */ + int gpuVertexCount = 0; /** * @brief Model-space bounding sphere used for view/cascade frustum culling. diff --git a/src/modules/graphics/RenderSystem3D.cpp b/src/modules/graphics/RenderSystem3D.cpp index afd4819b..3947d1f8 100644 --- a/src/modules/graphics/RenderSystem3D.cpp +++ b/src/modules/graphics/RenderSystem3D.cpp @@ -250,6 +250,8 @@ void Renderable3D::setScale(float sx, float sy, float sz) { void Renderable3D::setMesh(Mesh *mesh) { meshRenderer()->mesh = mesh; } +Mesh *Renderable3D::getMesh() { return meshRenderer()->mesh; } + void Renderable3D::setTexture(Texture *texture) { meshRenderer()->texture = texture; } void Renderable3D::setNormalTexture(Texture *texture) { meshRenderer()->normalTexture = texture; } diff --git a/src/modules/graphics/RenderSystem3D.h b/src/modules/graphics/RenderSystem3D.h index f6888e76..397edf80 100644 --- a/src/modules/graphics/RenderSystem3D.h +++ b/src/modules/graphics/RenderSystem3D.h @@ -174,6 +174,8 @@ public: float getYaw(); void setScale(float sx, float sy, float sz); void setMesh(Mesh *mesh); + /** @brief Main (non-part) mesh attached via setMesh; nullptr when unset. */ + Mesh *getMesh(); void setTexture(Texture *texture); void setNormalTexture(Texture *texture); /** @brief Height map for parallax (R channel; white = raised). nullptr disables sampling. */ diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index f33b8cb8..e36510d2 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -162,6 +162,7 @@ struct GpuMesh { /** @brief Number of dynamic updates; ring slot = count % kDynamicVertexCopies. */ uint64_t dynamicWriteCount = 0; bool dynamic = false; + uint32_t vertexCount = 0; uint32_t indexCount = 0; vk::IndexType indexType = vk::IndexType::eUint32; /** @brief Index into the GPU mesh table (GpuMeshRecord). */ diff --git a/src/modules/graphics/vulkan/GraphicsInternal.h b/src/modules/graphics/vulkan/GraphicsInternal.h index be9a1a15..0f599690 100644 --- a/src/modules/graphics/vulkan/GraphicsInternal.h +++ b/src/modules/graphics/vulkan/GraphicsInternal.h @@ -144,6 +144,7 @@ void ensureDynamicRing(GpuMesh &gpu) { void writeDynamicMesh(GpuMesh &gpu, const std::vector &verts, vkb::Device &device, vkb::FrameSlot frame, const uint32_t *indices, int indexCount) { const size_t slot = size_t(gpu.dynamicWriteCount % GpuMesh::kDynamicVertexCopies); + gpu.vertexCount = uint32_t(verts.size()); gpu.dynVertices[slot].allocate(frame, device, verts); if (indices && indexCount > 0) { gpu.cpuIndices.assign(indices, indices + indexCount); @@ -164,6 +165,7 @@ std::unique_ptr uploadGpuMesh(vkb::Device &device, vkb::FrameSlot frame const std::vector &vertices, const std::vector &indices) { auto gpu = std::make_unique(); + gpu->vertexCount = uint32_t(vertices.size()); gpu->vertices.allocate(frame, device, vertices); gpu->indices.allocate(frame, device, vk::BufferUsageFlagBits::eIndexBuffer, indices.size() * sizeof(uint32_t), kHostVisibleCoherent); @@ -177,6 +179,7 @@ std::unique_ptr uploadGpuMesh16(vkb::Device &device, vkb::FrameSlot fra const std::vector &vertices, const std::vector &indices) { auto gpu = std::make_unique(); + gpu->vertexCount = uint32_t(vertices.size()); gpu->vertices.allocate(frame, device, vertices); gpu->indices.allocate(frame, device, vk::BufferUsageFlagBits::eIndexBuffer, indices.size() * sizeof(uint16_t), kHostVisibleCoherent); @@ -189,6 +192,7 @@ std::unique_ptr uploadGpuMesh16(vkb::Device &device, vkb::FrameSlot fra std::unique_ptr makeMeshHandle(GpuMesh &gpu) { auto mesh = std::make_unique(); mesh->indexCount = int(gpu.indexCount); + mesh->gpuVertexCount = int(gpu.vertexCount); mesh->gpuHandle = &gpu; return mesh; } diff --git a/src/modules/graphics/vulkan/GraphicsMesh.cpp b/src/modules/graphics/vulkan/GraphicsMesh.cpp index a1d90fce..e8e19a3b 100644 --- a/src/modules/graphics/vulkan/GraphicsMesh.cpp +++ b/src/modules/graphics/vulkan/GraphicsMesh.cpp @@ -307,6 +307,7 @@ bool Graphics::updateMeshVertices(Mesh *mesh, const float *posXYZ, const float * // frames, just write the next copy. ensureDynamicRing(*gpu); writeDynamicMesh(*gpu, verts, getDevice(), frameToken(), indices, indexCount); + mesh->gpuVertexCount = int(gpu->vertexCount); mesh->indexCount = int(gpu->indexCount); return true; } diff --git a/src/modules/graphics/webgpu/Graphics.cpp b/src/modules/graphics/webgpu/Graphics.cpp index 54b8083e..eea5d73d 100644 --- a/src/modules/graphics/webgpu/Graphics.cpp +++ b/src/modules/graphics/webgpu/Graphics.cpp @@ -2045,6 +2045,7 @@ Mesh *Graphics::newMeshFromArrays(const float *posXYZ, const float *nrmXYZ, cons auto *mesh = new Mesh(); mesh->indexCount = indexCount; + mesh->gpuVertexCount = vertexCount; mesh->gpuHandle = gpu.get(); mesh->computeBounds(posXYZ, vertexCount); ownedGpuMeshes.push_back(std::move(gpu)); diff --git a/test/animation_skinned.cpp b/test/animation_skinned.cpp index d9114e53..e1460bfb 100644 --- a/test/animation_skinned.cpp +++ b/test/animation_skinned.cpp @@ -584,6 +584,98 @@ TEST_CASE("animation.skinned.cesiumMan.loadSkinAndDeform") { CHECK(vertexDeltaMax(skinned0, skinnedLive) > 1e-4f); } +TEST_CASE("animation.skinned.getSkinnedPositionsMatchesSkinPositionsTo") { + if (!ensureSkinnedAssets()) return; + + eve::ref model( + loadCesiumMan("ev_ut_animation_skinned_posarray")); + REQUIRE(model.get() != nullptr); + const int meshIndex = findFirstSkinnedMesh(model.get()); + REQUIRE(meshIndex >= 0); + + std::unique_ptr skeleton(AnimImporter::loadSkeletonFromModel(model.get())); + REQUIRE(skeleton.get() != nullptr); + std::unique_ptr skin(AnimSkin::fromModel(model.get(), meshIndex, skeleton.get())); + REQUIRE(skin.get() != nullptr); + + // Not skinned yet: packed getters are empty and the flags are false. + CHECK(skin->getSkinnedPositions().empty()); + CHECK(skin->getSkinnedNormals().empty()); + CHECK(!skin->hasSkinnedPositions()); + CHECK(!skin->hasSkinnedNormals()); + + std::unique_ptr clip( + AnimImporter::loadClipFromModel(model.get(), skeleton.get(), 0)); + REQUIRE(clip.get() != nullptr); + std::unique_ptr pose(new AnimPose()); + clip->sample(clip->getDuration() * 0.25f, pose.get(), skeleton.get()); + pose->computeWorld(skeleton.get()); + + REQUIRE(skin->updateSkinnedPositions(pose.get())); + CHECK(skin->hasSkinnedPositions()); + + std::vector expected; + REQUIRE(skin->skinPositionsTo(pose.get(), expected)); + const std::vector packed = skin->getSkinnedPositions(); + REQUIRE(packed.size() == expected.size()); + CHECK(vertexDeltaMax(packed, expected) < 1e-5f); +} + +TEST_CASE("animation.skinned.normalsFollowPose") { + if (!ensureSkinnedAssets()) return; + + eve::ref model( + loadCesiumMan("ev_ut_animation_skinned_normals")); + REQUIRE(model.get() != nullptr); + const int meshIndex = findFirstSkinnedMesh(model.get()); + REQUIRE(meshIndex >= 0); + const aiMesh *ai = model->getMesh(meshIndex); + REQUIRE(ai != nullptr); + REQUIRE(ai->HasNormals()); + REQUIRE(ai->mNormals != nullptr); + + std::vector bindNrm(size_t(ai->mNumVertices) * 3u); + for (int v = 0; v < int(ai->mNumVertices); ++v) { + bindNrm[size_t(v) * 3u + 0] = ai->mNormals[v].x; + bindNrm[size_t(v) * 3u + 1] = ai->mNormals[v].y; + bindNrm[size_t(v) * 3u + 2] = ai->mNormals[v].z; + } + + std::unique_ptr skeleton(AnimImporter::loadSkeletonFromModel(model.get())); + REQUIRE(skeleton.get() != nullptr); + std::unique_ptr skin(AnimSkin::fromModel(model.get(), meshIndex, skeleton.get())); + REQUIRE(skin.get() != nullptr); + + // Bind pose: skin matrices are identity, so normals must stay in place. + std::unique_ptr bind(new AnimPose()); + skeleton->applyBindPose(bind.get()); + bind->computeWorld(skeleton.get()); + REQUIRE(skin->updateSkinnedNormals(bind.get())); + CHECK(skin->hasSkinnedNormals()); + std::vector bindSkinned = skin->getSkinnedNormals(); + REQUIRE(bindSkinned.size() == bindNrm.size()); + CHECK(vertexDeltaMax(bindSkinned, bindNrm) < 1e-3f); + + // Mid-clip pose: at least one normal must rotate, and all stay unit length. + std::unique_ptr clip( + AnimImporter::loadClipFromModel(model.get(), skeleton.get(), 0)); + REQUIRE(clip.get() != nullptr); + std::unique_ptr mid(new AnimPose()); + clip->sample(clip->getDuration() * 0.5f, mid.get(), skeleton.get()); + mid->computeWorld(skeleton.get()); + REQUIRE(skin->updateSkinnedNormals(mid.get())); + const std::vector midSkinned = skin->getSkinnedNormals(); + REQUIRE(midSkinned.size() == bindNrm.size()); + CHECK(vertexDeltaMax(midSkinned, bindNrm) > 1e-3f); + for (size_t i = 0; i + 2 < midSkinned.size(); i += 3) { + const float nx = midSkinned[i]; + const float ny = midSkinned[i + 1]; + const float nz = midSkinned[i + 2]; + const float len = std::sqrt(nx * nx + ny * ny + nz * nz); + CHECK(std::fabs(len - 1.f) < 1e-3f); + } +} + TEST_CASE("animation.skinned.cesiumMan.animationFactory") { if (!ensureSkinnedAssets()) return; @@ -640,3 +732,139 @@ TEST_CASE("animation.skinned.render.playerDriven") { renderSkinnedAnimation(model.get(), "animation_skinned_player.png", SkinnedRenderDriver::player, 60); } + +TEST_CASE("animation.skinned.render.applyToMesh") { + if (!ensureSkinnedAssets()) return; + + eve::ref model( + loadCesiumMan("ev_ut_animation_skinned_render_apply")); + REQUIRE(model.get() != nullptr); + + const int meshIndex = findFirstSkinnedMesh(model.get()); + REQUIRE(meshIndex >= 0); + const aiMesh *ai = model->getMesh(meshIndex); + REQUIRE(ai != nullptr); + REQUIRE(ai->mNumVertices > 0u); + REQUIRE(ai->mNumFaces > 0u); + const int vertexCount = static_cast(ai->mNumVertices); + + std::vector pos, nrm, uv; + pos.reserve(size_t(vertexCount) * 3u); + for (int v = 0; v < vertexCount; ++v) { + pos.push_back(ai->mVertices[v].x); + pos.push_back(ai->mVertices[v].y); + pos.push_back(ai->mVertices[v].z); + } + if (ai->mNormals) { + nrm.reserve(size_t(vertexCount) * 3u); + for (int v = 0; v < vertexCount; ++v) { + nrm.push_back(ai->mNormals[v].x); + nrm.push_back(ai->mNormals[v].y); + nrm.push_back(ai->mNormals[v].z); + } + } + if (ai->mTextureCoords[0]) { + uv.reserve(size_t(vertexCount) * 2u); + for (int v = 0; v < vertexCount; ++v) { + uv.push_back(ai->mTextureCoords[0][v].x); + uv.push_back(ai->mTextureCoords[0][v].y); + } + } + std::vector indices; + indices.reserve(size_t(ai->mNumFaces) * 3u); + for (unsigned f = 0; f < ai->mNumFaces; ++f) { + const aiFace &face = ai->mFaces[f]; + REQUIRE(face.mNumIndices == 3u); + for (unsigned k = 0; k < 3u; ++k) indices.push_back(face.mIndices[k]); + } + const int indexCount = static_cast(indices.size()); + + Bounds b; + for (int v = 0; v < vertexCount; ++v) + b.expand(ai->mVertices[v].x, ai->mVertices[v].y, ai->mVertices[v].z); + REQUIRE(b.valid); + const float cx = b.centerX(), cy = b.centerY(), cz = b.centerZ(); + const float rad = std::max(0.5f, b.radius()); + + eve::window::Window *win = nullptr; + Graphics *gfx = nullptr; + openGfxWindow(win, gfx); + resetScene3D(); + + Texture *diffuse = loadCesiumManDiffuseTexture(gfx); + Texture *tex = diffuse ? diffuse : makeSolidGray(gfx, 200); + + auto *ent = Renderable3D::create(); + ent->setTexture(tex); + ent->setTint(1.f, 1.f, 1.f, 1.f); + ent->meshRenderer()->visible = true; + ent->meshRenderer()->castShadow = false; + ent->meshRenderer()->receiveShadow = false; + + auto *cam = Camera3D::createCamera(); + cam->setTarget(cx, cy, cz); + cam->setEye(cx + rad * 0.9f, cy + rad * 0.55f, cz + rad * 1.8f); + cam->data()->nearZ = std::max(0.05f, rad * 0.01f); + cam->data()->farZ = std::max(100.f, rad * 20.f); + cam->setAmbient(0.15f, 0.15f, 0.15f); + RenderSystem3D::setDirectionalLight( + cx - (cx + rad * 0.9f), cy - (cy + rad * 0.55f) + rad * 1.2f, + cz - (cz + rad * 1.8f), 1.8f, 1.8f, 1.8f); + gfx->setScreenReadbackEnabled(true); + gfx->setBackgroundColor(Color(0.02f, 0.02f, 0.03f, 1.f)); + + // Mesh created once; skinned results are written back in place per frame. + Mesh *mesh = gfx->newMeshFromArrays(pos.data(), nrm.empty() ? nullptr : nrm.data(), + uv.empty() ? nullptr : uv.data(), vertexCount, + indices.data(), indexCount); + REQUIRE(mesh != nullptr); + CHECK(mesh->getVertexCount() == vertexCount); + ent->meshRenderer()->mesh = mesh; + + std::unique_ptr skeleton(AnimImporter::loadSkeletonFromModel(model.get())); + REQUIRE(skeleton.get() != nullptr); + std::unique_ptr skin(AnimSkin::fromModel(model.get(), meshIndex, skeleton.get())); + REQUIRE(skin.get() != nullptr); + CHECK(skin->getVertexCount() == vertexCount); + std::unique_ptr clip( + AnimImporter::loadClipFromModel(model.get(), skeleton.get(), 0)); + REQUIRE(clip.get() != nullptr); + std::unique_ptr player(new AnimPlayer(skeleton.get())); + player->play(clip.get()); + + std::vector firstLuma; + float maxDelta = -1.f; + bool first = true; + for (int i = 0; i < 30; ++i) { + player->update(1.f / 30.f); + AnimPose *pose = player->getPose(); + REQUIRE(pose != nullptr); + pose->computeWorld(skeleton.get()); + REQUIRE(skin->applyToMesh(gfx, mesh, pose)); + + RenderSystem3D::render(*gfx); + RenderSystem::render(*gfx); + SDL_Event e; + while (SDL_PollEvent(&e)) { + if (e.type == SDL_QUIT) { + win->close(); + return; + } + } + SDL_Delay(16); + + const auto L = captureLumaGrid(gfx); + if (first) { + firstLuma = L; + first = false; + } else { + maxDelta = std::max(maxDelta, maxLumaAbsDelta(gfx, firstLuma, L)); + } + } + + const auto last = captureLumaGrid(gfx); + CHECK(foregroundPixelCount(last, 0.15f) > 20); + CHECK(maxDelta > 0.01f); + savePng(gfx, "animation_skinned_apply.png"); + win->close(); +}