forge-pipeline-morph-animations

Add morph target (blend shape) animations with pipeline assets to an SDL GPU project using ForgeSceneMorphModel.

SKILL.md

GitHub

Add morph target animation support to an SDL GPU project that already uses forge_scene.h. This skill covers loading .fmesh files with morph data, evaluating morph weight animation from .fanim channels, CPU-blending per-vertex deltas, and uploading to GPU storage buffers for vertex shader displacement.

Prerequisites

  • forge_scene.h with FORGE_SCENE_MODEL_SUPPORT enabled
  • forge_pipeline.h for .fmesh and .fanim loading
  • Pipeline-processed assets with FLAG_MORPHS set in .fmesh
  • Compiled morph shaders (scene_morph.vert.hlsl, scene_morph_shadow.vert.hlsl)

Asset processing

Process glTF models with morph targets using the pipeline tools:

forge_mesh_tool model.gltf model.fmesh --verbose
forge_scene_tool model.gltf model.fscene --verbose
forge_anim_tool model.gltf model.fanim --verbose

The mesh tool detects morph targets and sets FLAG_MORPHS in the .fmesh header. It also disables vertex deduplication (morph deltas reference original vertex indices). The anim tool preserves MORPH_WEIGHTS channels (target_path = 3) with variable component counts.

Loading a morph model

#define FORGE_SCENE_MODEL_SUPPORT
#define FORGE_SCENE_IMPLEMENTATION
#include "scene/forge_scene.h"

ForgeSceneMorphModel morph_model;

/* fmat_path may be NULL if no materials; fanim_path may be NULL for manual weights */
if (!forge_scene_load_morph_model(&scene, &morph_model,
        fscene_path, fmesh_path, fmat_path, fanim_path, base_dir)) {
    SDL_Log("Failed to load morph model");
    return SDL_APP_FAILURE;
}

Updating morph animation each frame

/* After forge_scene_begin_frame(): */
morph_model.anim_speed = speed_slider_value;

/* Toggle manual_weights to override animation with direct control */
if (morph_model.manual_weights) {
    morph_model.morph_weights[0] = slider_w0;
    morph_model.morph_weights[1] = slider_w1;
}

forge_scene_update_morph_animation(&scene, &morph_model, dt);

forge_scene_update_morph_animation does three things:

  1. Evaluates morph weight channels from .fanim (unless manual_weights is set)
  2. CPU-blends position and normal deltas: blended[v] += weight * delta[v]
  3. Uploads blended deltas to GPU storage buffers via persistent transfer buffer

Drawing

mat4 placement = mat4_multiply(
    mat4_translate(vec3_create(x, y, z)),
    mat4_scale_uniform(scale));

/* Shadow pass */
forge_scene_begin_shadow_pass(&scene);
forge_scene_draw_morph_model_shadows(&scene, &morph_model, placement);
forge_scene_end_shadow_pass(&scene);

/* Main pass */
forge_scene_begin_main_pass(&scene);
forge_scene_draw_grid(&scene);
forge_scene_draw_morph_model(&scene, &morph_model, placement);
forge_scene_end_main_pass(&scene);

Cleanup

forge_scene_free_morph_model(&scene, &morph_model);

Shader approach

The morph vertex shader uses the standard 48-byte vertex layout (same as scene_model.vert.hlsl) plus two StructuredBuffer<float4> for position and normal deltas, indexed by SV_VertexID. The float4 type ensures a consistent 16-byte stride across SPIRV and DXIL backends:

StructuredBuffer<float4> morph_pos_deltas : register(t0, space0);
StructuredBuffer<float4> morph_nrm_deltas : register(t1, space0);

float3 morphed_pos = input.position + morph_pos_deltas[input.vertex_id].xyz;
float3 morphed_nrm = normalize(input.normal + morph_nrm_deltas[input.vertex_id].xyz);

The shadow variant uses only position deltas (1 storage buffer).

Reference