319 lines
19 KiB
JavaScript
Executable File
319 lines
19 KiB
JavaScript
Executable File
// Original articulated woodland sculptures. Pure offline geometry and pixel art;
|
|
// no imported assets, filesystem, fonts, random state or engine-side fake motion.
|
|
import { PALETTE } from './world-art.mjs';
|
|
|
|
export const FAUNA_NAMES = Object.freeze('hare deer fox rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm'.split(' '));
|
|
export const FAUNA_ANIMATIONS = Object.freeze(Object.fromEntries(
|
|
['idle','locomotion','feed','flight_swim','rest'].map((id,i)=>[id,Object.freeze([i*20,i*20+19,24])])));
|
|
export const FAUNA_MATERIALS = Object.freeze(['coat','belly','face','ear','foot','tail','wing','eye',
|
|
'nose','stripe','feather','beak','fin','spot','horn','membrane']);
|
|
// width, shoulder height, body length, head size; model units, +Z forward.
|
|
const FORMS = Object.freeze({
|
|
hare:[2.5,4.1,5,1.8], deer:[5,10,10,2.7], fox:[3,4.6,7,2],
|
|
rabbit:[2.8,2.8,3.8,1.7], red_squirrel:[1.5,2.5,3.2,1.2],
|
|
bank_vole:[1.4,1.1,2.2,.9], wood_mouse:[1.2,1.5,2,.85], hedgehog:[2.2,1.5,3.1,1],
|
|
badger:[3.8,3,6.6,1.9], roe_deer:[3.5,7.8,7.2,2.1],
|
|
stoat:[1.2,1.5,4.5,.95], weasel:[.85,1.1,3.1,.7], otter:[2.5,2.3,6.2,1.6],
|
|
robin:[1.4,2.2,2,1.15], blackbird:[1.6,2.5,2.8,1.2], blue_tit:[1,1.9,1.6,.9],
|
|
barn_owl:[2.6,3.8,2.5,2], mallard:[2.5,2.8,4,1.4],
|
|
common_frog:[1.8,1,2.2,1.4], smooth_newt:[.8,.65,2.5,.65],
|
|
brown_trout:[1.1,1.4,4.8,1], bumblebee:[.8,.7,1.3,.55], earthworm:[.4,.4,3.8,.35],
|
|
});
|
|
const BIRDS = new Set(['robin','blackbird','blue_tit','barn_owl','mallard']);
|
|
const AQUATIC = new Set(['otter','mallard','common_frog','smooth_newt','brown_trout']);
|
|
const COLORS = {
|
|
hare:['fur','cream','barkLight'],deer:['deer','cream','bark'],fox:['fox','cream','barkDark'],
|
|
rabbit:['earthLight','cream','fur'],red_squirrel:['copper','cream','rust'],
|
|
bank_vole:['earthLight','strawLight','bark'],wood_mouse:['fur','cream','rose'],
|
|
hedgehog:['barkDark','fur','wood'],badger:['slate','cream','ink'],
|
|
roe_deer:['rust','cream','bark'],stoat:['earthLight','cream','ink'],weasel:['barkLight','cream','earth'],
|
|
otter:['bark','cream','earthDark'],robin:['barkLight','rust','cream'],
|
|
blackbird:['ink','slate','ochre'],blue_tit:['slateBlue','gold','cream'],
|
|
barn_owl:['wood','cream','barkLight'],mallard:['earthLight','cream','pine'],
|
|
common_frog:['leaf','ochre','leafDark'],smooth_newt:['earth','ochre','barkDark'],
|
|
brown_trout:['willow','cream','rust'],bumblebee:['ochre','ink','cream'],earthworm:['rose','clay','wine'],
|
|
};
|
|
function valid(id) { if(!FAUNA_NAMES.includes(id)) throw new Error('Unknown fauna species'); }
|
|
const sub=(a,b)=>a.map((v,i)=>v-b[i]);
|
|
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
|
const unit=a=>a.map(v=>v/Math.hypot(...a));
|
|
const faces=[[0,3,2,1],[4,5,6,7],[0,1,5,4],[3,7,6,2],[0,4,7,3],[1,2,6,5]];
|
|
class Rig {
|
|
bones=[]; vertices=[]; triangles=[];
|
|
bone(name,parent,pivot,role='still',phase=0) {
|
|
const b={name,parent,pivot,role,phase,weights:[]}; this.bones.push(b); return b;
|
|
}
|
|
box(b,center,size,material='coat',taper=1) {
|
|
const [x,y,z]=center, [w,h,l]=size.map(v=>v/2);
|
|
const corners=[[-w,-h,-l],[w,-h,-l],[w,h,-l],[-w,h,-l],[-w,-h,l],[w,-h,l],[w,h,l],[-w,h,l]]
|
|
.map(([a,c,d])=>[x+a*(c<0?taper:1),y+c,z+d*(c<0?taper:1)]);
|
|
const materials=Array.isArray(material)?material:Array(6).fill(material);
|
|
faces.forEach((f,j)=> {
|
|
const normal=unit(cross(sub(corners[f[1]],corners[f[0]]),sub(corners[f[2]],corners[f[0]])));
|
|
const tile=FAUNA_MATERIALS.indexOf(materials[j]);
|
|
if(tile<0) throw new Error('Unknown fauna material');
|
|
const u=(tile%4)*16,v=Math.floor(tile/4)*16, first=this.vertices.length;
|
|
f.forEach((index,k)=> {
|
|
const [a,c]=[[.5,15.5],[15.5,15.5],[15.5,.5],[.5,.5]][k];
|
|
this.vertices.push([...corners[index],...normal,(u+a)/64,(v+c)/64]); b.weights.push(first+k);
|
|
});
|
|
this.triangles.push([first,first+1,first+2],[first,first+2,first+3]);
|
|
});
|
|
}
|
|
}
|
|
function sculpture(id) {
|
|
valid(id);
|
|
const m=new Rig(), [w,h,l,hs]=FORMS[id];
|
|
const root=m.bone('Root',null,[0,0,0]);
|
|
const body=m.bone('Body',root,[0,h,0],'breathe');
|
|
const put=(b,c,s,mat='coat',t=1)=>m.box(b,c,s,mat,t);
|
|
if(id==='earthworm') {
|
|
let parent=body;
|
|
for(let i=0;i<12;i++) {
|
|
const z=-l/2+(i+.5)*l/12, b=m.bone(`Segment_${i}`,parent,[0,h/2,z],'segment',i*.65);
|
|
put(b,[0,h/2,z],[w*(i===0||i===11?.65:1),h,l/12*1.08],i===7||i===8?'belly':'coat',.85);
|
|
parent=b;
|
|
}
|
|
return m;
|
|
}
|
|
if(id==='brown_trout') {
|
|
put(body,[0,h,0],[w,h*1.6,l*.7],['coat','face','belly','coat','spot','spot'],.55);
|
|
const head=m.bone('Head',body,[0,h,l*.25],'head');
|
|
put(head,[0,h,l*.35],[w*.8,h*1.1,l*.3],'face',.65);
|
|
for(const s of [-1,1]) put(head,[s*w*.42,h*1.2,l*.41],[.09,.18,.18],'eye');
|
|
const tail=m.bone('Tail',body,[0,h,-l*.3],'swim_tail');
|
|
put(tail,[0,h,-l*.46],[w*.4,h*.7,l*.3],'coat',.8);
|
|
const tip=m.bone('Tail_Tip',tail,[0,h,-l*.55],'swim_tail',.8);
|
|
put(tip,[0,h,-l*.63],[w*.22,h*2,l*.22],'fin',.65);
|
|
put(body,[0,h*1.85,-l*.07],[w*.24,h*.65,l*.3],'fin',.5);
|
|
for(const s of [-1,1]) {
|
|
const fin=m.bone(`Pectoral_${s}`,body,[s*w*.4,h*.8,l*.16],'fin',s);
|
|
put(fin,[s*w*.65,h*.65,l*.06],[w*.6,h*.2,l*.27],'fin',.5);
|
|
}
|
|
return m;
|
|
}
|
|
if(id==='bumblebee') {
|
|
put(body,[0,h,0],[w,h,l],['coat','face','belly','stripe','stripe','stripe'],.8);
|
|
const head=m.bone('Head',body,[0,h,l*.45],'head');
|
|
put(head,[0,h,l*.55],[hs,hs,hs],'face');
|
|
for(const s of [-1,1]) {
|
|
put(head,[s*hs*.4,h+.1,l*.78],[.12,.18,.1],'eye');
|
|
put(head,[s*.18,h+.35,l*.64],[.07,.35,.08],'foot');
|
|
const wing=m.bone(`Wing_${s}`,body,[s*w*.35,h*1.5,0],'wing',s);
|
|
put(wing,[s*w,h*1.5,-.05],[w*1.6,.045,l*.65],'membrane',.65);
|
|
for(let j=0;j<3;j++) {
|
|
const leg=m.bone(`Leg_${s}_${j}`,body,[s*w*.4,h*.7,(j-1)*.35],'leg',s+j);
|
|
put(leg,[s*w*.6,h*.25,(j-1)*.35],[.08,h*.5,.1],'foot');
|
|
}
|
|
}
|
|
return m;
|
|
}
|
|
const bird=BIRDS.has(id), frog=id==='common_frog', newt=id==='smooth_newt';
|
|
const legHeight=bird?h*.6:h*.75;
|
|
put(body,[0,h,0],[w,h*(bird?1.05:.65),l],['coat','face','belly','coat','coat','coat'],.78);
|
|
const neckY=bird?h*1.43:h*1.16, neckZ=l*(bird?.28:.39);
|
|
const neck=m.bone('Neck',body,[0,h,neckZ],'neck');
|
|
if(['deer','roe_deer','mallard'].includes(id)) put(neck,[0,h*1.25,neckZ],[hs*.65,h*.65,hs*.8],'coat',.8);
|
|
const head=m.bone('Head',neck,[0,neckY,neckZ],'head');
|
|
put(head,[0,neckY,neckZ+hs*.3],[hs,hs*(frog?.45:bird?1:.8),hs],bird?['coat','face','belly','coat','face','face']:'face',.85);
|
|
const muzzle=bird?hs*.45:['fox','badger','hedgehog'].includes(id)?hs*.65:hs*.3;
|
|
put(head,[0,neckY-hs*.12,neckZ+hs*.8],[hs*(bird?.35:.6),hs*.3,muzzle],bird?'beak':'belly',.6);
|
|
if(!bird) put(head,[0,neckY-hs*.1,neckZ+hs*.8+muzzle*.51],[hs*.23,hs*.16,.07],'nose');
|
|
for(const s of [-1,1]) {
|
|
put(head,[s*hs*.5,neckY+hs*.13,neckZ+hs*.48],[hs*.08,hs*.16,hs*.16],'eye');
|
|
if(bird) continue;
|
|
if(!frog&&!newt) {
|
|
const long=id==='hare'?1.5:id==='rabbit'?.95:id==='red_squirrel'?.65:.38;
|
|
const ear=m.bone(`Ear_${s}`,head,[s*hs*.35,neckY+hs*.3,neckZ],'ear',s);
|
|
put(ear,[s*hs*.38,neckY+hs*(.38+long/2),neckZ],[hs*.28,hs*long,hs*.22],'ear',.65);
|
|
}
|
|
if(id==='deer'||id==='roe_deer') {
|
|
const antler=m.bone(`Antler_${s}`,head,[s*hs*.35,neckY+hs*.3,neckZ]);
|
|
for(let j=0;j<(id==='deer'?3:2);j++) {
|
|
put(antler,[s*(hs*.4+j*.38),neckY+hs*(.7+j*.28),neckZ-j*.27],[.22,hs*.7,.22],'horn');
|
|
put(antler,[s*(hs*.6+j*.4),neckY+hs*(.6+j*.28),neckZ-.3],[hs*.55,.2,.22],'horn');
|
|
}
|
|
}
|
|
}
|
|
if(bird) {
|
|
for(const s of [-1,1]) {
|
|
const wing=m.bone(`Wing_${s}`,body,[s*w*.42,h*1.2,l*.15],'wing',s);
|
|
put(wing,[s*w*.65,h,-l*.08],[w*.48,h*.65,l*.85],'wing',.6);
|
|
const tip=m.bone(`Wing_Tip_${s}`,wing,[s*w*.82,h*.9,-l*.25],'wing_tip',s);
|
|
for(let j=0;j<3;j++) put(tip,[s*(w*.88+j*.13),h*.75,-l*(.25+j*.09)],
|
|
[w*.22,h*.45,l*.35],'feather',.6);
|
|
}
|
|
if(id==='barn_owl') {
|
|
// Pale facial discs separated by a narrow brow; not a recoloured songbird.
|
|
for(const s of [-1,1]) put(head,[s*hs*.24,neckY,neckZ+hs*.83],[hs*.45,hs*.77,.08],'belly',.75);
|
|
for(const s of [-1,1]) put(head,[s*hs*.23,neckY+.08,neckZ+hs*.89],[.17,.23,.05],'eye');
|
|
}
|
|
}
|
|
for(const s of [-1,1]) for(const j of (bird?[1]:[-1,1])) {
|
|
const x=s*w*.35,z=j*l*.32, phase=s*(j===1?1:-1);
|
|
const upper=m.bone(`Leg_${s}_${j}`,body,[x,legHeight,z],'leg',phase);
|
|
const hind=(id==='hare'||id==='rabbit'||frog)&&j===-1;
|
|
put(upper,[x,legHeight*.72,z],[w*(hind?.4:.18),legHeight*.56,w*.23],'coat',.8);
|
|
const lower=m.bone(`Shin_${s}_${j}`,upper,[x,legHeight*.45,z],'shin',phase);
|
|
put(lower,[x,legHeight*.24,z],[w*.14,legHeight*.46,w*.17],'foot');
|
|
put(lower,[x,legHeight*.07,z+w*.15],[w*(id==='mallard'?.48:.23),legHeight*.14,w*(hind?.8:.52)],bird?'beak':'foot');
|
|
}
|
|
const tailLength={fox:l*.8,red_squirrel:l*1.05,wood_mouse:l*1.15,bank_vole:l*.3,
|
|
stoat:l*.55,weasel:l*.4,otter:l*.7,smooth_newt:l*.95}[id] || (bird?l*.65:l*.2);
|
|
const tail=m.bone('Tail',body,[0,h*.95,-l*.45],AQUATIC.has(id)?'swim_tail':'tail');
|
|
const bushy=['fox','red_squirrel'].includes(id), thick=bushy?w*.7:newt?w*.6:w*.3;
|
|
const squirrel=id==='red_squirrel', tailY=squirrel?h*1.4:h*.95;
|
|
put(tail,[0,tailY,-l*.5-tailLength*.22],[thick,squirrel?tailLength*.65:thick,tailLength*.58],bird?'feather':'tail',.7);
|
|
const tip=m.bone('Tail_Tip',tail,[0,tailY,-l*.5-tailLength*.45],AQUATIC.has(id)?'swim_tail':'tail',.8);
|
|
put(tip,[0,squirrel?h*2:tailY,-l*.5-tailLength*.68],[thick*.65,squirrel?tailLength*.55:thick*.6,tailLength*.47],id==='stoat'||id==='fox'?'stripe':bird?'feather':'tail',.6);
|
|
if(id==='hedgehog') for(let j=0;j<4;j++) for(const s of [-1,0,1])
|
|
put(body,[s*w*.3,h*1.4,-l*.3+j*l*.2],[w*.25,h*.5,l*.2],'stripe',.35);
|
|
return m;
|
|
}
|
|
|
|
function rotation(x,y,z) {
|
|
const [a,b,c]=[x,y,z].map(v=>Math.cos(v/2)),[d,e,f]=[x,y,z].map(v=>Math.sin(v/2));
|
|
return [a*b*c+d*e*f,d*b*c-a*e*f,a*e*c+d*b*f,a*b*f-d*e*c];
|
|
}
|
|
function pose(id,b,frame) {
|
|
const clip=Math.floor(frame/20),t=(frame%20)/19, wave=Math.sin(t*Math.PI*2), swell=1-Math.cos(t*Math.PI*2);
|
|
let x=0,y=0,z=0,lift=0;
|
|
const moving=clip===1, swimming=clip===3&&AQUATIC.has(id), flying=clip===3&&(BIRDS.has(id)||id==='bumblebee');
|
|
if(b.role==='breathe') lift=FORMS[id][1]*.008*swell;
|
|
if(b.role==='head') { y=.07*wave; if(clip===2) x=.42+.17*swell; if(clip===4) x=.16; }
|
|
if(b.role==='neck' && clip===2) x=.3+.13*swell;
|
|
if(b.role==='ear' && clip===0) z=b.phase*.08*wave;
|
|
if(b.role==='leg' && (moving||swimming)) x=.6*wave*b.phase*(swimming?.55:1);
|
|
if(b.role==='shin' && moving) x=.35*Math.max(0,-wave*b.phase);
|
|
if((b.role==='wing'||b.role==='wing_tip') && flying) {
|
|
z=b.phase*(b.role==='wing'?1.15:.5)*wave; x=.12*wave;
|
|
}
|
|
if(b.role==='tail') { y=(moving?.23:.07)*wave; if(clip===4) y=.25; }
|
|
if(b.role==='swim_tail') y=(moving||swimming?.42:.06)*Math.sin(t*Math.PI*2+b.phase);
|
|
if(b.role==='fin') z=.25*wave*b.phase;
|
|
if(b.role==='segment') { y=(moving?.23:clip===2?.07:.025)*Math.sin(t*Math.PI*2+b.phase); }
|
|
// Ground animals have an alert/paddle reserve clip, never fictitious wings.
|
|
if(clip===3&&!swimming&&!flying&&b.role==='head') y=.22*wave;
|
|
if(clip===4 && b.role==='leg') x=.12*b.phase;
|
|
return {lift,rotation:rotation(x,y,z)};
|
|
}
|
|
const i32=(...values)=>{ const b=Buffer.alloc(values.length*4);values.forEach((n,i)=>b.writeInt32LE(n,i*4));return b; };
|
|
const f32=(...values)=>{ const b=Buffer.alloc(values.length*4);values.forEach((n,i)=>b.writeFloatLE(n,i*4));return b; };
|
|
const str=s=>Buffer.from(s+'\0','ascii');
|
|
const chunk=(tag,...parts)=>{const b=Buffer.concat(parts);return Buffer.concat([Buffer.from(tag),i32(b.length),b]);};
|
|
export function faunaB3D(id) {
|
|
const m=sculpture(id);
|
|
const mesh=chunk('MESH',i32(0),chunk('VRTS',i32(1,1,2),...m.vertices.map(v=>f32(...v))),
|
|
chunk('TRIS',i32(0),...m.triangles.map(t=>i32(...t))));
|
|
function node(b) {
|
|
const local=b.parent?sub(b.pivot,b.parent.pivot):b.pivot, keys=[];
|
|
for(let frame=0;frame<100;frame++) {
|
|
const p=pose(id,b,frame);
|
|
// Irrlicht reads file keys 1..100 as runtime frames 0..99.
|
|
keys.push(i32(frame+1),f32(local[0],local[1]+p.lift,local[2],...p.rotation));
|
|
}
|
|
return chunk('NODE',str(b.name),f32(...local,1,1,1,1,0,0,0),
|
|
...(b.parent?[]:[mesh,chunk('ANIM',i32(0,100),f32(24))]),
|
|
...(b.weights.length?[chunk('BONE',...b.weights.map(v=>Buffer.concat([i32(v),f32(1)])))]:[]),
|
|
chunk('KEYS',i32(5),...keys),...m.bones.filter(c=>c.parent===b).map(node));
|
|
}
|
|
return chunk('BB3D',i32(1),chunk('TEXS',str(`hearth_fauna_${id}.png`),i32(1,2),f32(0,0,1,1,0)),
|
|
chunk('BRUS',i32(1),str('Original fauna semantic atlas'),f32(1,1,1,1,0),i32(1,0,0)),node(m.bones[0]));
|
|
}
|
|
export function faunaBounds(id) {
|
|
const {vertices}=sculpture(id);
|
|
return {min:[0,1,2].map(i=>Math.min(...vertices.map(v=>v[i]))/10),
|
|
max:[0,1,2].map(i=>Math.max(...vertices.map(v=>v[i]))/10)};
|
|
}
|
|
export function faunaTexture(id) {
|
|
valid(id);
|
|
const data=new Uint8Array(64*64*4), [coat,belly,accent]=COLORS[id];
|
|
const bases=[coat,belly,coat,'rose',accent,coat,coat,'ink','barkDark',accent,accent,'ochre',accent,coat,'woodLight','moon'];
|
|
for(let tile=0;tile<16;tile++) for(let y=0;y<16;y++) for(let x=0;x<16;x++) {
|
|
const mat=FAUNA_MATERIALS[tile]; let color=bases[tile];
|
|
// Broad directional clusters, never hash noise. Material-specific landmarks.
|
|
if(y===3 && x>2 && x<13 && !['eye','nose','membrane'].includes(mat)) color=belly;
|
|
if(mat==='coat' && y>10 && x%5<2) color=accent;
|
|
if(mat==='face') {
|
|
if(id==='badger') color=x<4||x>11?'ink':'cream';
|
|
else if(id==='mallard') color=y>11?'cream':'pine';
|
|
else if(id==='blue_tit') color=y<5?'slateBlue':x<3||x>12?'ink':'cream';
|
|
else if(id==='barn_owl') color=x<2||x>13?'wood':'cream';
|
|
else if(id==='robin') color=y>5?'rust':coat;
|
|
else if(y>9) color=belly;
|
|
}
|
|
if(mat==='ear') color=x<3||x>12?coat:y>4?'rose':belly;
|
|
if(mat==='tail'&&id==='red_squirrel' && (x+y)%7<2) color='copper';
|
|
if(mat==='stripe'&&id==='bumblebee') color=y%8<4?'ink':'ochre';
|
|
if(mat==='stripe'&&id==='fox') color='cream';
|
|
if(mat==='stripe'&&id==='stoat') color='ink';
|
|
if(mat==='stripe'&&id==='hedgehog') color=x%4<2?'wood':'barkDark';
|
|
if(mat==='wing'||mat==='feather'||mat==='fin') {
|
|
if(x%4===0&&y>5) color=belly;
|
|
if(id==='mallard'&&mat==='wing'&&y>5&&y<11) color=x<3||x>12?'cream':'slateBlue';
|
|
if(id==='blue_tit'&&mat==='wing') color=y%6===0?'cream':'slateBlue';
|
|
}
|
|
if(mat==='spot'&&((x>=3&&x<=5&&y>=4&&y<=6)||(x>=10&&x<=12&&y>=11&&y<=13))) color='rust';
|
|
if(mat==='eye'&&x>=3&&x<6&&y>=2&&y<5) color='cream';
|
|
if(mat==='membrane') color=x%6===0||y===8?'slateBlue':'moon';
|
|
data.set([...PALETTE[color],255],((Math.floor(tile/4)*16+y)*64+(tile%4)*16+x)*4);
|
|
}
|
|
return {width:64,height:64,data};
|
|
}
|
|
export const FAUNA_ART_MANIFEST = Object.freeze(FAUNA_NAMES.map(id=>Object.freeze({
|
|
id,mesh:`hearth_fauna_${id}.b3d`,texture:`hearth_fauna_${id}.png`,width:64,height:64,
|
|
unitsPerNode:10,visualSize:1,bounds:Object.freeze(Object.fromEntries(Object.entries(faunaBounds(id)).map(([k,v])=>[k,Object.freeze(v)]))),
|
|
posture:BIRDS.has(id)?'perched; articulated folded wings':id==='brown_trout'?'horizontal swimming':
|
|
id==='bumblebee'?'six-legged; wings at thorax':id==='earthworm'?'grounded segmented body':'grounded quadruped',
|
|
clips:FAUNA_ANIMATIONS,license:'MIT',author:'The Last Hearth original procedural art',
|
|
})));
|
|
|
|
function rotate(q,v) {
|
|
const a=q.slice(1), t=cross(a,v).map(n=>n*2),c=cross(a,t);
|
|
return v.map((n,i)=>n+q[0]*t[i]+c[i]);
|
|
}
|
|
// Actual skinned mesh portrait, CPU triangle rasterisation, no font/engine. Each
|
|
// row-major 128px cell fits that animal's posed bounds, not a scale comparison.
|
|
export function faunaPortraitSheet(frame=9) {
|
|
if(!Number.isInteger(frame)||frame<0||frame>99) throw new Error('Unknown fauna frame');
|
|
const width=512,height=768,data=new Uint8Array(width*height*4);
|
|
for(let y=0;y<height;y++) for(let x=0;x<width;x++) data.set([...PALETTE[(Math.floor(x/8)+Math.floor(y/8))%2?'outline':'ink'],255],(y*width+x)*4);
|
|
FAUNA_NAMES.forEach((id,index)=> {
|
|
const m=sculpture(id),atlas=faunaTexture(id), posed=[];
|
|
// Apply each ancestor's local rotation around its bind pivot, leaf to root.
|
|
for(const b of m.bones) for(const vi of b.weights) {
|
|
let v=m.vertices[vi].slice(0,3), n=m.vertices[vi].slice(3,6);
|
|
for(let p=b;p;p=p.parent) {
|
|
const {rotation:q,lift}=pose(id,p,frame);
|
|
v=rotate(q,sub(v,p.pivot)).map((a,i)=>a+p.pivot[i]+(i===1?lift:0)); n=rotate(q,n);
|
|
}
|
|
posed[vi]={v,n};
|
|
}
|
|
// Front three-quarter view: +Z muzzle toward the camera. Use a matching
|
|
// depth basis, otherwise a perfectly valid depth buffer shows only backs.
|
|
const project=([x,y,z])=>[x*.8-z*.6,x*.18-y*.954+z*.24,x*.572+y*.3+z*.763];
|
|
const ps=posed.map(p=>project(p.v)), bounds=[0,1].map(i=>[Math.min(...ps.map(p=>p[i])),Math.max(...ps.map(p=>p[i]))]);
|
|
const scale=104/Math.max(...bounds.map(([a,b])=>b-a)), ox=index%4*128+64,oy=Math.floor(index/4)*128+64;
|
|
const points=ps.map(p=>[(p[0]-(bounds[0][0]+bounds[0][1])/2)*scale+ox,(p[1]-(bounds[1][0]+bounds[1][1])/2)*scale+oy,p[2]]);
|
|
const depth=new Float64Array(128*128).fill(-Infinity);
|
|
for(const ids of m.triangles) {
|
|
const [a,b,c]=ids.map(i=>points[i]), area=(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]);
|
|
if(Math.abs(area)<1e-8) continue;
|
|
const normal=posed[ids[0]].n, light=.68+.32*Math.max(0,normal[0]*-.3+normal[1]*.8+normal[2]*.5);
|
|
const x0=Math.max(ox-64,Math.floor(Math.min(a[0],b[0],c[0]))),x1=Math.min(ox+63,Math.ceil(Math.max(a[0],b[0],c[0])));
|
|
const y0=Math.max(oy-64,Math.floor(Math.min(a[1],b[1],c[1]))),y1=Math.min(oy+63,Math.ceil(Math.max(a[1],b[1],c[1])));
|
|
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) {
|
|
const u=((b[0]-x-.5)*(c[1]-y-.5)-(b[1]-y-.5)*(c[0]-x-.5))/area;
|
|
const v=((c[0]-x-.5)*(a[1]-y-.5)-(c[1]-y-.5)*(a[0]-x-.5))/area,w=1-u-v;
|
|
if(Math.min(u,v,w)<-1e-7) continue;
|
|
const z=u*a[2]+v*b[2]+w*c[2],local=(y-(oy-64))*128+x-(ox-64);
|
|
if(z<depth[local]) continue; depth[local]=z;
|
|
const uv=[6,7].map(k=>ids.reduce((sum,id,j)=>sum+m.vertices[id][k]*[u,v,w][j],0));
|
|
const tex=(Math.min(63,Math.floor(uv[1]*64))*64+Math.min(63,Math.floor(uv[0]*64)))*4;
|
|
data.set([0,1,2].map(k=>Math.round(atlas.data[tex+k]*light)).concat(255),(y*width+x)*4);
|
|
}
|
|
}
|
|
});
|
|
return {width,height,data};
|
|
}
|