From 626073cc3924f132118eb87d3861b83ed72d1706 Mon Sep 17 00:00:00 2001 From: fallenoak Date: Fri, 26 Jan 2024 22:41:49 -0600 Subject: [PATCH] feat(model): add skinning and bone animations --- package-lock.json | 8 +-- package.json | 2 +- src/lib/model/Model.ts | 21 ++++++- src/lib/model/ModelAnimation.ts | 46 +++++++++++++-- src/lib/model/ModelAnimator.ts | 36 ++++++++---- src/lib/model/ModelManager.ts | 45 ++++++++++++--- src/lib/model/ModelMaterial.ts | 7 +++ src/lib/model/loader/ModelLoaderWorker.ts | 57 +++++++++++++++++++ src/lib/model/loader/types.ts | 14 ++++- src/lib/model/shader/vertex.ts | 69 ++++++++++++++++++++++- src/lib/shader/util.ts | 28 +++++++-- 11 files changed, 296 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37c302d..f46c7e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@tweenjs/tween.js": "^23.1.1", - "@wowserhq/format": "^0.21.0" + "@wowserhq/format": "^0.22.0" }, "devDependencies": { "@commitlint/config-conventional": "^18.5.0", @@ -2342,9 +2342,9 @@ } }, "node_modules/@wowserhq/format": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@wowserhq/format/-/format-0.21.0.tgz", - "integrity": "sha512-4H5h7c2brmNOc5DZH8iHdZIjh18FBymWnOmcKbPg1fmMzbkrCwulnY+VLpMnseGYhtG3Zx773o7pDtWmSFU1cw==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@wowserhq/format/-/format-0.22.0.tgz", + "integrity": "sha512-zLfKT8aMQW6SD6CBkrqPGOeS9C5S0lhaG0LMjE9HQFoApaBXSCCAF4+Gf3J6j/5/wFP0F5a2XjKRnJzkeLw2gw==", "dependencies": { "@wowserhq/io": "^2.0.2", "gl-matrix": "^3.4.3" diff --git a/package.json b/package.json index d1d0a70..f5a2002 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ ], "dependencies": { "@tweenjs/tween.js": "^23.1.1", - "@wowserhq/format": "^0.21.0" + "@wowserhq/format": "^0.22.0" }, "peerDependencies": { "three": "^0.160.0" diff --git a/src/lib/model/Model.ts b/src/lib/model/Model.ts index 79dbb92..c9eda03 100644 --- a/src/lib/model/Model.ts +++ b/src/lib/model/Model.ts @@ -16,16 +16,29 @@ class Model extends THREE.Object3D { geometry: THREE.BufferGeometry, materials: THREE.Material[], animator: ModelAnimator, + skinned: boolean, ) { super(); - this.#mesh = new THREE.Mesh(geometry, materials); + // Avoid skinning overhead when model does not make use of bone animations + if (skinned) { + this.#mesh = new THREE.SkinnedMesh(geometry, materials); + } else { + this.#mesh = new THREE.Mesh(geometry, materials); + } + this.#mesh.onBeforeRender = this.#onBeforeRender.bind(this); this.add(this.#mesh); // Every model instance gets a unique animation state managed by a single animator this.animation = animator.createAnimation(this); + // Every skinned model instance gets a unique skeleton + if (skinned) { + this.#mesh.add(...this.animation.rootBones); + (this.#mesh as THREE.SkinnedMesh).bind(this.animation.skeleton); + } + this.diffuseColor = new THREE.Color(1.0, 1.0, 1.0); this.emissiveColor = new THREE.Color(0.0, 0.0, 0.0); this.alpha = 1.0; @@ -39,6 +52,12 @@ class Model extends THREE.Object3D { material: ModelMaterial, group: THREE.Group, ) { + // Ensure bone matrices are updated (matrix world auto-updates are disabled) + if ((this.#mesh as THREE.SkinnedMesh).isSkinnedMesh) { + this.#mesh.updateMatrixWorld(); + } + + // Update material uniforms to match animation states material.prepareMaterial(this); } diff --git a/src/lib/model/ModelAnimation.ts b/src/lib/model/ModelAnimation.ts index 8e9ae9e..8c7c4c0 100644 --- a/src/lib/model/ModelAnimation.ts +++ b/src/lib/model/ModelAnimation.ts @@ -2,6 +2,7 @@ import * as THREE from 'three'; import { ModelMaterialColor, ModelTextureTransform } from './types.js'; import Model from './Model.js'; import ModelAnimator from './ModelAnimator.js'; +import { BoneSpec } from './loader/types.js'; class ModelAnimation extends THREE.Object3D { // States @@ -9,16 +10,26 @@ class ModelAnimation extends THREE.Object3D { textureTransforms: ModelTextureTransform[] = []; materialColors: ModelMaterialColor[] = []; + // Skeleton + skeleton: THREE.Skeleton; + rootBones: THREE.Bone[]; + #model: Model; #animator: ModelAnimator; #actions: Set = new Set(); - constructor(model: Model, animator: ModelAnimator, stateCounts: Record) { + constructor( + model: Model, + animator: ModelAnimator, + bones: BoneSpec[], + stateCounts: Record, + ) { super(); this.#model = model; this.#animator = animator; this.#createStates(stateCounts); + this.#createSkeleton(bones); this.#autoplay(); } @@ -29,6 +40,8 @@ class ModelAnimation extends THREE.Object3D { } this.#actions.clear(); + + this.skeleton.dispose(); } #createStates(stateCounts: Record) { @@ -52,6 +65,27 @@ class ModelAnimation extends THREE.Object3D { } } + #createSkeleton(boneSpecs: BoneSpec[]) { + const bones: THREE.Bone[] = []; + const rootBones: THREE.Bone[] = []; + + for (const boneSpec of boneSpecs) { + const bone = new THREE.Bone(); + bone.visible = false; + bone.position.set(boneSpec.position[0], boneSpec.position[1], boneSpec.position[2]); + bones.push(bone); + + if (boneSpec.parentIndex === -1) { + rootBones.push(bone); + } else { + bones[boneSpec.parentIndex].add(bone); + } + } + + this.skeleton = new THREE.Skeleton(bones); + this.rootBones = rootBones; + } + #autoplay() { // Automatically play all loops for (let i = 0; i < this.#animator.loops.length; i++) { @@ -59,12 +93,14 @@ class ModelAnimation extends THREE.Object3D { this.#actions.add(action); } - // Automatically play flagged sequences - for (let i = 0; i < this.#animator.sequences.length; i++) { - const sequence = this.#animator.sequences[i]; + // Automatically play sequence id 0 + if (this.#animator.sequences.has(0)) { + const variations = this.#animator.sequences.get(0); + const sequence = variations[0]; if (sequence.flags & 0x20) { - const action = this.#animator.getSequence(this, i).play(); + const action = this.#animator.getSequence(this, sequence.id, sequence.variationIndex); + action.play(); this.#actions.add(action); } } diff --git a/src/lib/model/ModelAnimator.ts b/src/lib/model/ModelAnimator.ts index a47b66c..395f823 100644 --- a/src/lib/model/ModelAnimator.ts +++ b/src/lib/model/ModelAnimator.ts @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { M2Track } from '@wowserhq/format'; -import { SequenceSpec } from './loader/types.js'; +import { BoneSpec, SequenceSpec } from './loader/types.js'; import ModelAnimation from './ModelAnimation.js'; import Model from './Model.js'; @@ -20,12 +20,15 @@ class ModelAnimator { #loops: number[] = []; #loopClips: THREE.AnimationClip[] = []; - #sequences: SequenceSpec[] = []; - #sequenceClips: THREE.AnimationClip[] = []; + #sequencesByIndex: SequenceSpec[] = []; + #sequences: Map = new Map(); + #sequenceClips: Map = new Map(); + + #bones: BoneSpec[] = []; #stateCounts: Record = {}; - constructor(loops: Uint32Array, sequences: SequenceSpec[]) { + constructor(loops: Uint32Array, sequences: SequenceSpec[], bones: BoneSpec[]) { this.#mixer = new THREE.AnimationMixer(new THREE.Object3D()); this.#mixer.timeScale = 1000; @@ -36,10 +39,12 @@ class ModelAnimator { for (const sequence of sequences) { this.#registerSequence(sequence); } + + this.#bones = bones; } createAnimation(model: Model) { - return new ModelAnimation(model, this, this.#stateCounts); + return new ModelAnimation(model, this, this.#bones, this.#stateCounts); } get loops() { @@ -64,8 +69,8 @@ class ModelAnimator { return this.#mixer.clipAction(clip, root); } - getSequence(root: THREE.Object3D, index: number) { - const clip = this.#sequenceClips[index]; + getSequence(root: THREE.Object3D, id: number, variationIndex: number) { + const clip = this.#sequenceClips.get(id)[variationIndex]; return this.#mixer.clipAction(clip, root); } @@ -138,7 +143,8 @@ class ModelAnimator { transform?: (value: any) => any, ) { for (let s = 0; s < track.sequenceTimes.length; s++) { - const clip = this.#sequenceClips[s]; + const sequence = this.#sequencesByIndex[s]; + const clip = this.#sequenceClips.get(sequence.id)[sequence.variationIndex]; const times = track.sequenceTimes[s]; const values = transform @@ -161,9 +167,17 @@ class ModelAnimator { } #registerSequence(spec: SequenceSpec) { - const index = this.#sequences.length; - this.#sequences[index] = spec; - this.#sequenceClips[index] = new THREE.AnimationClip(`sequence-${index}`, spec.duration, []); + if (!this.#sequences.has(spec.id)) { + this.#sequences.set(spec.id, []); + this.#sequenceClips.set(spec.id, []); + } + + this.#sequences.get(spec.id)[spec.variationIndex] = spec; + this.#sequencesByIndex.push(spec); + + const clipName = `sequence-${spec.id}-${spec.variationIndex}`; + const clip = new THREE.AnimationClip(clipName, spec.duration, []); + this.#sequenceClips.get(spec.id)[spec.variationIndex] = clip; } } diff --git a/src/lib/model/ModelManager.ts b/src/lib/model/ModelManager.ts index 5a661dd..563a9fe 100644 --- a/src/lib/model/ModelManager.ts +++ b/src/lib/model/ModelManager.ts @@ -16,6 +16,7 @@ type ModelResources = { geometry: THREE.BufferGeometry; materials: THREE.Material[]; animator: ModelAnimator; + skinned: boolean; }; type ModelManagerOptions = { @@ -86,6 +87,7 @@ class ModelManager { geometry, materials, animator, + skinned: spec.skinned, }; this.#loaded.set(refId, resources); @@ -105,13 +107,13 @@ class ModelManager { const boneWeights = new THREE.InterleavedBuffer(new Uint8Array(vertexBuffer), 48); geometry.setAttribute( - 'boneWeights', - new THREE.InterleavedBufferAttribute(boneWeights, 4, 12, false), + 'skinWeight', + new THREE.InterleavedBufferAttribute(boneWeights, 4, 12, true), ); const boneIndices = new THREE.InterleavedBuffer(new Uint8Array(vertexBuffer), 48); geometry.setAttribute( - 'boneIndices', + 'skinIndex', new THREE.InterleavedBufferAttribute(boneIndices, 4, 16, false), ); @@ -152,10 +154,12 @@ class ModelManager { } #createMaterials(spec: ModelSpec) { - return Promise.all(spec.materials.map((materialSpec) => this.#createMaterial(materialSpec))); + return Promise.all( + spec.materials.map((materialSpec) => this.#createMaterial(materialSpec, spec.skinned)), + ); } - async #createMaterial(spec: MaterialSpec) { + async #createMaterial(spec: MaterialSpec, skinned: boolean) { const vertexShader = getVertexShader(spec.vertexShader); const fragmentShader = getFragmentShader(spec.fragmentShader); const textures = await Promise.all( @@ -173,6 +177,7 @@ class ModelManager { textureWeightIndex, textureTransformIndices, materialColorIndex, + skinned, uniforms, spec.blend, spec.flags, @@ -195,7 +200,12 @@ class ModelManager { } #createModel(resources: ModelResources) { - const model = new Model(resources.geometry, resources.materials, resources.animator); + const model = new Model( + resources.geometry, + resources.materials, + resources.animator, + resources.skinned, + ); model.name = resources.name; @@ -207,7 +217,7 @@ class ModelManager { return null; } - const animator = new ModelAnimator(spec.loops, spec.sequences); + const animator = new ModelAnimator(spec.loops, spec.sequences, spec.bones); for (const [index, textureWeight] of spec.textureWeights.entries()) { animator.registerTrack( @@ -253,6 +263,27 @@ class ModelManager { ); } + for (const [index, bone] of spec.bones.entries()) { + animator.registerTrack( + { state: 'bones', index, property: 'position' }, + bone.positionTrack, + THREE.VectorKeyframeTrack, + ); + + animator.registerTrack( + { state: 'bones', index, property: 'quaternion' }, + bone.rotationTrack, + THREE.QuaternionKeyframeTrack, + (value: number) => (value > 0 ? value - 0x7fff : value + 0x7fff) / 0x7fff, + ); + + animator.registerTrack( + { state: 'bones', index, property: 'scale' }, + bone.scaleTrack, + THREE.VectorKeyframeTrack, + ); + } + return animator; } } diff --git a/src/lib/model/ModelMaterial.ts b/src/lib/model/ModelMaterial.ts index 8cf5c1b..4f5c30e 100644 --- a/src/lib/model/ModelMaterial.ts +++ b/src/lib/model/ModelMaterial.ts @@ -32,6 +32,7 @@ class ModelMaterial extends THREE.RawShaderMaterial { textureWeightIndex: number, textureTransformIndices: number[], colorIndex: number, + skinned: boolean = false, uniforms: Record = {}, blend = DEFAULT_BLEND, flags = DEFAULT_FLAGS, @@ -65,6 +66,12 @@ class ModelMaterial extends THREE.RawShaderMaterial { this.fogged = flags & M2_MATERIAL_FLAG.FLAG_DISABLE_FOG ? 0.0 : 1.0; this.alpha = DEFAULT_ALPHA; + if (skinned) { + this.defines = { + USE_SKINNING: 1, + }; + } + this.glslVersion = THREE.GLSL3; this.vertexShader = vertexShader; this.fragmentShader = fragmentShader; diff --git a/src/lib/model/loader/ModelLoaderWorker.ts b/src/lib/model/loader/ModelLoaderWorker.ts index 0960be0..3b06fad 100644 --- a/src/lib/model/loader/ModelLoaderWorker.ts +++ b/src/lib/model/loader/ModelLoaderWorker.ts @@ -29,6 +29,7 @@ class ModelLoaderWorker extends SceneWorker { const geometry = this.#createGeometrySpec(model, skinProfile); const materials = this.#createMaterialSpecs(skinProfile); + const { bones, skinned } = this.#createBoneSpecs(model); const sequences = this.#createSequenceSpecs(model); const loops = model.loops; const textureWeights = model.textureWeights; @@ -39,6 +40,8 @@ class ModelLoaderWorker extends SceneWorker { name: model.name, geometry, materials, + bones, + skinned, loops, sequences, textureWeights, @@ -127,6 +130,60 @@ class ModelLoaderWorker extends SceneWorker { aliasNext: sequence.aliasNext, })); } + + #createBoneSpecs(model: M2Model) { + const boneSpecs = []; + let skinned = false; + + for (const bone of model.bones) { + let position = bone.pivot; + + // Convert pivot to absolute position + let parentBone = boneSpecs[bone.parentIndex]; + while (parentBone) { + position[0] -= parentBone.position[0]; + position[1] -= parentBone.position[1]; + position[2] -= parentBone.position[2]; + + parentBone = boneSpecs[parentBone.parentIndex]; + } + + // Convert translation track to position track + const positionTrack = bone.translationTrack; + for (let s = 0; s < positionTrack.sequenceKeys.length; s++) { + const values = positionTrack.sequenceKeys[s]; + for (let i = 0; i < values.length / 3; i++) { + values[i * 3] += position[0]; + values[i * 3 + 1] += position[1]; + values[i * 3 + 2] += position[2]; + } + } + + // If bone animations are present, the model needs skinning + const hasTranslationAnim = bone.translationTrack.sequenceTimes.length > 0; + const hasRotationAnim = bone.rotationTrack.sequenceTimes.length > 0; + const hasScaleAnim = bone.scaleTrack.sequenceTimes.length > 0; + if (hasTranslationAnim || hasRotationAnim || hasScaleAnim) { + skinned = true; + } + + // If bone is billboarded, the model needs skinning + if (bone.flags & (0x8 | 0x10 | 0x20 | 0x40)) { + skinned = true; + } + + boneSpecs.push({ + position, + parentIndex: bone.parentIndex, + flags: bone.flags, + positionTrack: positionTrack, + rotationTrack: bone.rotationTrack, + scaleTrack: bone.scaleTrack, + }); + } + + return { bones: boneSpecs, skinned }; + } } export default ModelLoaderWorker; diff --git a/src/lib/model/loader/types.ts b/src/lib/model/loader/types.ts index f5c3d91..ad199a8 100644 --- a/src/lib/model/loader/types.ts +++ b/src/lib/model/loader/types.ts @@ -6,6 +6,7 @@ import { M2Color, M2TextureTransform, M2TextureWeight, + M2Track, } from '@wowserhq/format'; type TextureSpec = { @@ -50,10 +51,21 @@ type SequenceSpec = { aliasNext: number; }; +type BoneSpec = { + position: Float32Array; + parentIndex: number; + flags: number; + positionTrack: M2Track; + rotationTrack: M2Track; + scaleTrack: M2Track; +}; + type ModelSpec = { name: string; geometry: GeometrySpec; materials: MaterialSpec[]; + bones: BoneSpec[]; + skinned: boolean; sequences: SequenceSpec[]; loops: Uint32Array; textureWeights: M2TextureWeight[]; @@ -61,4 +73,4 @@ type ModelSpec = { materialColors: M2Color[]; }; -export { ModelSpec, MaterialSpec, TextureSpec, SequenceSpec }; +export { ModelSpec, BoneSpec, MaterialSpec, TextureSpec, SequenceSpec }; diff --git a/src/lib/model/shader/vertex.ts b/src/lib/model/shader/vertex.ts index 4188599..1620a80 100644 --- a/src/lib/model/shader/vertex.ts +++ b/src/lib/model/shader/vertex.ts @@ -9,6 +9,9 @@ import { composeShader } from '../../shader/util.js'; const VERTEX_SHADER_PRECISION = 'highp float'; const VERTEX_SHADER_UNIFORMS = [ + { name: 'bindMatrix', type: 'mat4', if: 'USE_SKINNING' }, + { name: 'bindMatrixInverse', type: 'mat4', if: 'USE_SKINNING' }, + { name: 'boneTexture', type: 'highp sampler2D', if: 'USE_SKINNING' }, { name: 'modelMatrix', type: 'mat4' }, { name: 'modelViewMatrix', type: 'mat4' }, { name: 'normalMatrix', type: 'mat3' }, @@ -21,6 +24,8 @@ const VERTEX_SHADER_UNIFORMS = [ const VERTEX_SHADER_INPUTS = [ { name: 'position', type: 'vec3' }, { name: 'normal', type: 'vec3' }, + { name: 'skinIndex', type: 'vec4', if: 'USE_SKINNING' }, + { name: 'skinWeight', type: 'vec4', if: 'USE_SKINNING' }, ]; const VERTEX_SHADER_OUTPUTS = [{ name: 'vLight', type: 'float' }]; @@ -37,10 +42,50 @@ vec2 sphereMap(vec3 position, vec3 normal) { } `; -const VERTEX_SHADER_FUNCTIONS = [VERTEX_SHADER_SPHERE_MAP]; +const VERTEX_SHADER_GET_BONE_MATRIX = ` +#ifdef USE_SKINNING + mat4 getBoneMatrix(const in float i) { + int size = textureSize(boneTexture, 0).x; + int j = int(i) * 4; + int x = j % size; + int y = j / size; + + vec4 v1 = texelFetch(boneTexture, ivec2(x, y), 0); + vec4 v2 = texelFetch(boneTexture, ivec2(x + 1, y), 0); + vec4 v3 = texelFetch(boneTexture, ivec2(x + 2, y), 0); + vec4 v4 = texelFetch(boneTexture, ivec2(x + 3, y), 0); + + return mat4(v1, v2, v3, v4); + } +#endif +`; + +const VERTEX_SHADER_FUNCTIONS = [VERTEX_SHADER_SPHERE_MAP, VERTEX_SHADER_GET_BONE_MATRIX]; + +const VERTEX_SHADER_MAIN_SKINNING = ` +#ifdef USE_SKINNING + mat4 boneMatX = getBoneMatrix(skinIndex.x); + mat4 boneMatY = getBoneMatrix(skinIndex.y); + mat4 boneMatZ = getBoneMatrix(skinIndex.z); + mat4 boneMatW = getBoneMatrix(skinIndex.w); +#endif +`; const VERTEX_SHADER_MAIN_LIGHTING = ` -vec3 viewNormal = normalize(normalMatrix * normal); +vec3 objectNormal = normal; + +#ifdef USE_SKINNING + mat4 skinMatrix = mat4(0.0); + skinMatrix += skinWeight.x * boneMatX; + skinMatrix += skinWeight.y * boneMatY; + skinMatrix += skinWeight.z * boneMatZ; + skinMatrix += skinWeight.w * boneMatW; + skinMatrix = bindMatrixInverse * skinMatrix * bindMatrix; + + objectNormal = vec4(skinMatrix * vec4(objectNormal, 0.0)).xyz; +#endif + +vec3 viewNormal = normalize(normalMatrix * objectNormal); vLight = clamp(dot(viewNormal, -sunDir), 0.0, 1.0); `; @@ -52,7 +97,21 @@ ${VARIABLE_FOG_FACTOR.name} = calculateFogFactor(${UNIFORM_FOG_PARAMS.name}, cam `; const VERTEX_SHADER_MAIN_POSITION = ` -gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +#ifdef USE_SKINNING + vec4 skinVertex = bindMatrix * vec4(position, 1.0); + + vec4 skinned = vec4(0.0); + skinned += boneMatX * skinVertex * skinWeight.x; + skinned += boneMatY * skinVertex * skinWeight.y; + skinned += boneMatZ * skinVertex * skinWeight.z; + skinned += boneMatW * skinVertex * skinWeight.w; + + vec3 skinnedPosition = (bindMatrixInverse * skinned).xyz; + + gl_Position = projectionMatrix * modelViewMatrix * vec4(skinnedPosition, 1.0); +#else + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +#endif `; const createVertexShader = (texCoord1?: M2_TEXTURE_COORD, texCoord2?: M2_TEXTURE_COORD) => { @@ -116,8 +175,12 @@ const createVertexShader = (texCoord1?: M2_TEXTURE_COORD, texCoord2?: M2_TEXTURE main.push(`vTexCoord2 = sphereMap(position, normal);`); } + main.push(VERTEX_SHADER_MAIN_SKINNING); + main.push(VERTEX_SHADER_MAIN_LIGHTING); + main.push(VERTEX_SHADER_MAIN_FOG); + main.push(VERTEX_SHADER_MAIN_POSITION); return composeShader(precision, uniforms, inputs, outputs, functions, main); diff --git a/src/lib/shader/util.ts b/src/lib/shader/util.ts index 36bf015..c15cf97 100644 --- a/src/lib/shader/util.ts +++ b/src/lib/shader/util.ts @@ -1,7 +1,7 @@ const composeShader = ( precision: string, - uniforms: { name: string; type: string }[], - inputs: { name: string; type: string }[], + uniforms: { name: string; type: string; if?: string }[], + inputs: { name: string; type: string; if?: string }[], outputs: { name: string; type: string }[], functions: string[], main: string[], @@ -11,10 +11,30 @@ const composeShader = ( lines.push(`precision ${precision};`); lines.push(''); - lines.push(...uniforms.map((uniform) => `uniform ${uniform.type} ${uniform.name};`)); + for (const uniform of uniforms) { + if (uniform.if) { + lines.push(`#ifdef ${uniform.if}`); + } + + lines.push(`uniform ${uniform.type} ${uniform.name};`); + + if (uniform.if) { + lines.push(`#endif`); + } + } lines.push(''); - lines.push(...inputs.map((input) => `in ${input.type} ${input.name};`)); + for (const input of inputs) { + if (input.if) { + lines.push(`#ifdef ${input.if}`); + } + + lines.push(`in ${input.type} ${input.name};`); + + if (input.if) { + lines.push(`#endif`); + } + } lines.push(''); lines.push(...outputs.map((output) => `out ${output.type} ${output.name};`));