feat: add missing ears mod features (#6788)

This commit is contained in:
Calum H.
2026-07-21 17:19:52 +00:00
committed by GitHub
parent 916daa684d
commit f780736d89
2 changed files with 436 additions and 58 deletions
@@ -210,7 +210,7 @@ function getModelUrlForVariant(variant: string): string {
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>()) export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>()) export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false const DEBUG_MODE = false
const SKIN_PREVIEW_RENDER_VERSION = 'ears-1' const SKIN_PREVIEW_RENDER_VERSION = 'ears-2'
let sharedRenderer: BatchSkinRenderer | null = null let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0 let latestPreviewGeneration = 0
@@ -1,7 +1,10 @@
/* /*
* Portions of this file are adapted from Ears and the Ears Skin Manipulator: * Portions of this file are adapted from Ears, Ears2, and the Ears Skin Manipulator:
* https://github.com/exaskye/Ears * https://git.sleeping.town/exa.mods/Ears
* Source revision: 5d050d461d207c1a7cda0de11e2562f6581f7a16 * https://git.sleeping.town/exa.mods/Ears2
* Source revisions:
* - Ears: 5d050d461d207c1a7cda0de11e2562f6581f7a16
* - Ears2: 8ad9adc213e3b16b1a3306fb4141cfb618c25a6f
* *
* MIT License * MIT License
* *
@@ -59,16 +62,26 @@ type WingMode =
| 'ASYMMETRIC_R' | 'ASYMMETRIC_R'
| 'ASYMMETRIC_DUAL' | 'ASYMMETRIC_DUAL'
| 'FLAT' | 'FLAT'
type WingAnimationMode = 'NORMAL' | 'NONE' | 'NO_FLIGHT'
type LegMode = 'PLANTIGRADE' | 'DIGITIGRADE_PARTIAL' | 'DIGITIGRADE_FULL'
type Protrusions =
| 'NONE'
| 'HORN'
| 'CLAWS'
| 'CLAWS_AND_HORN'
| 'HALO'
| 'DOUBLE_HALO'
| 'CLAWS_AND_HALO'
| 'CLAWS_AND_DOUBLE_HALO'
type BodyPart = 'HEAD' | 'TORSO' | 'LEFT_ARM' | 'RIGHT_ARM' | 'LEFT_LEG' | 'RIGHT_LEG' type BodyPart = 'HEAD' | 'TORSO' | 'LEFT_ARM' | 'RIGHT_ARM' | 'LEFT_LEG' | 'RIGHT_LEG'
type TextureSource = 'skin' | 'wing' | 'cape' type TextureSource = 'skin' | 'displaced-skin' | 'wing' | 'cape'
type TextureRotation = 'NONE' | 'CW' | 'CCW' | 'UPSIDE_DOWN' type TextureRotation = 'NONE' | 'CW' | 'CCW' | 'UPSIDE_DOWN'
type TextureFlip = 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'BOTH' type TextureFlip = 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'BOTH'
interface EarsFeatures { interface EarsFeatures {
earMode: EarMode earMode: EarMode
earAnchor: EarAnchor earAnchor: EarAnchor
claws: boolean protrusions: Protrusions
horn: boolean
tailMode: TailMode tailMode: TailMode
tailSegments: number tailSegments: number
tailBends: [number, number, number, number] tailBends: [number, number, number, number]
@@ -78,9 +91,12 @@ interface EarsFeatures {
snoutDepth: number snoutDepth: number
chestSize: number chestSize: number
wingMode: WingMode wingMode: WingMode
animateWings: boolean wingAnimationMode: WingAnimationMode
capeEnabled: boolean capeEnabled: boolean
emissive: boolean emissive: boolean
legMode: LegMode
animateTail: boolean
swapJacketBackAndTail: boolean
} }
interface TexturePair { interface TexturePair {
@@ -150,6 +166,24 @@ const WING_MODES: WingMode[] = [
'ASYMMETRIC_DUAL', 'ASYMMETRIC_DUAL',
'FLAT', 'FLAT',
] ]
const WING_ANIMATION_MODES: WingAnimationMode[] = ['NORMAL', 'NONE', 'NO_FLIGHT']
const LEG_MODES: LegMode[] = ['PLANTIGRADE', 'DIGITIGRADE_PARTIAL', 'DIGITIGRADE_FULL']
const PROTRUSIONS: Protrusions[] = [
'NONE',
'HORN',
'CLAWS',
'CLAWS_AND_HORN',
'HALO',
'DOUBLE_HALO',
'CLAWS_AND_HALO',
'CLAWS_AND_DOUBLE_HALO',
]
const CLAW_PROTRUSIONS = new Set<Protrusions>([
'CLAWS',
'CLAWS_AND_HORN',
'CLAWS_AND_HALO',
'CLAWS_AND_DOUBLE_HALO',
])
const MAGIC_PIXELS = { const MAGIC_PIXELS = {
BLUE: 0x3f23d8, BLUE: 0x3f23d8,
@@ -189,6 +223,54 @@ const FORCED_OPAQUE_REGIONS = [
[16, 52, 48, 64], [16, 52, 48, 64],
] as const ] as const
const FORCED_OPAQUE_REGIONS_WITHOUT_LEG_BOTTOM = [
[8, 0, 24, 8],
[0, 8, 32, 16],
[4, 16, 8, 20],
[20, 16, 36, 20],
[44, 16, 52, 20],
[0, 20, 16, 38],
[16, 20, 56, 32],
[20, 48, 24, 52],
[36, 48, 44, 52],
[16, 52, 32, 58],
[32, 52, 48, 64],
] as const
const FORCED_OPAQUE_REGIONS_WITHOUT_LEGS = [
[8, 0, 24, 8],
[0, 8, 32, 16],
[4, 16, 8, 20],
[20, 16, 36, 20],
[44, 16, 52, 20],
[16, 20, 56, 32],
[20, 48, 24, 52],
[36, 48, 44, 52],
[32, 52, 48, 64],
] as const
const LEG_BOTTOM_HALF_REGIONS = [
[24, 48, 28, 52, true],
[16, 58, 32, 64, true],
[8, 16, 12, 20, true],
[0, 26, 16, 32, true],
[8, 32, 12, 36, false],
[0, 42, 16, 48, false],
[8, 48, 12, 52, false],
[0, 58, 16, 64, false],
] as const
const LEG_REGIONS = [
[20, 48, 28, 52, true],
[16, 52, 32, 64, true],
[4, 16, 12, 20, true],
[0, 20, 16, 32, true],
[4, 32, 12, 36, false],
[0, 36, 16, 48, false],
[4, 48, 12, 52, false],
[0, 52, 16, 64, false],
] as const
const BODY_PART_NODES: Record<BodyPart, string> = { const BODY_PART_NODES: Record<BodyPart, string> = {
HEAD: 'Head', HEAD: 'Head',
TORSO: 'Body', TORSO: 'Body',
@@ -303,18 +385,18 @@ function parseV1Features(data: Uint8ClampedArray): EarsFeatures | null {
try { try {
const reader = new BitReader(new Uint8Array(bytes)) const reader = new BitReader(new Uint8Array(bytes))
reader.read(8) const version = reader.read(8)
const ears = reader.read(6) const ears = reader.read(6)
const earMode = ears === 0 ? 'NONE' : (EAR_MODES[Math.floor((ears - 1) / 3) + 1] ?? 'NONE') const earMode = ears === 0 ? 'NONE' : (EAR_MODES[Math.floor((ears - 1) / 3) + 1] ?? 'NONE')
const earAnchor = ears === 0 ? 'CENTER' : (EAR_ANCHORS[(ears - 1) % 3] ?? 'CENTER') const earAnchor = ears === 0 ? 'CENTER' : (EAR_ANCHORS[(ears - 1) % 3] ?? 'CENTER')
const claws = reader.readBoolean() let protrusionsIndex = reader.read(2)
const horn = reader.readBoolean() const tailIndex = reader.read(3)
const tailMode = TAIL_MODES[reader.read(3)] ?? 'NONE' let tailMode = TAIL_MODES[tailIndex] ?? 'NONE'
let tailSegments = 0 let tailSegments = 0
const tailBends: [number, number, number, number] = [0, 0, 0, 0] const tailBends: [number, number, number, number] = [0, 0, 0, 0]
if (tailMode !== 'NONE') { if (tailIndex !== 0) {
tailSegments = reader.read(2) + 1 tailSegments = reader.read(2) + 1
for (let index = 0; index < tailSegments; index++) { for (let index = 0; index < tailSegments; index++) {
tailBends[index] = reader.readSignedUnit(6) * 90 tailBends[index] = reader.readSignedUnit(6) * 90
@@ -336,12 +418,30 @@ function parseV1Features(data: Uint8ClampedArray): EarsFeatures | null {
const animateWings = wingMode === 'NONE' ? false : reader.readBoolean() const animateWings = wingMode === 'NONE' ? false : reader.readBoolean()
const capeEnabled = reader.readBoolean() const capeEnabled = reader.readBoolean()
const emissive = reader.readBoolean() const emissive = reader.readBoolean()
let wingAnimationMode: WingAnimationMode = animateWings ? 'NORMAL' : 'NONE'
let legMode: LegMode = 'PLANTIGRADE'
let animateTail = true
let swapJacketBackAndTail = false
if (version >= 1 && tailIndex === 7) {
tailMode = TAIL_MODES[tailIndex + reader.read(3)] ?? 'NONE'
}
if (version >= 2) {
legMode = LEG_MODES[reader.read(3)] ?? 'PLANTIGRADE'
if (wingMode !== 'NONE') {
wingAnimationMode = WING_ANIMATION_MODES[reader.read(3)] ?? wingAnimationMode
}
animateTail = reader.readBoolean()
swapJacketBackAndTail = reader.readBoolean()
}
if (version >= 3) {
protrusionsIndex |= reader.read(2) << 2
}
return { return {
earMode, earMode,
earAnchor, earAnchor,
claws, protrusions: PROTRUSIONS[protrusionsIndex] ?? 'NONE',
horn,
tailMode, tailMode,
tailSegments, tailSegments,
tailBends, tailBends,
@@ -351,9 +451,12 @@ function parseV1Features(data: Uint8ClampedArray): EarsFeatures | null {
snoutDepth, snoutDepth,
chestSize, chestSize,
wingMode, wingMode,
animateWings, wingAnimationMode,
capeEnabled, capeEnabled,
emissive, emissive,
legMode,
animateTail,
swapJacketBackAndTail,
} }
} catch { } catch {
return null return null
@@ -406,12 +509,31 @@ function parseV0Features(data: Uint8ClampedArray): EarsFeatures {
[MAGIC_PIXELS.PURPLE, 'ASYMMETRIC_DUAL'], [MAGIC_PIXELS.PURPLE, 'ASYMMETRIC_DUAL'],
[MAGIC_PIXELS.PURPLE2, 'FLAT'], [MAGIC_PIXELS.PURPLE2, 'FLAT'],
]) ])
const protrusionsByPixel = new Map<number, Protrusions>([
[MAGIC_PIXELS.BLUE, 'NONE'],
[MAGIC_PIXELS.RED, 'NONE'],
[MAGIC_PIXELS.GREEN, 'CLAWS'],
[MAGIC_PIXELS.PURPLE, 'HORN'],
[MAGIC_PIXELS.CYAN, 'CLAWS_AND_HORN'],
[MAGIC_PIXELS.WHITE, 'HALO'],
[MAGIC_PIXELS.GRAY, 'DOUBLE_HALO'],
[MAGIC_PIXELS.PURPLE2, 'CLAWS_AND_HALO'],
[MAGIC_PIXELS.PINK, 'CLAWS_AND_DOUBLE_HALO'],
])
const wingAnimationModeByPixel = new Map<number, WingAnimationMode>([
[MAGIC_PIXELS.BLUE, 'NORMAL'],
[MAGIC_PIXELS.RED, 'NONE'],
[MAGIC_PIXELS.GREEN, 'NO_FLIGHT'],
])
const legModeByPixel = new Map<number, LegMode>([
[MAGIC_PIXELS.BLUE, 'PLANTIGRADE'],
[MAGIC_PIXELS.GREEN, 'DIGITIGRADE_PARTIAL'],
[MAGIC_PIXELS.PINK, 'DIGITIGRADE_FULL'],
])
const earMode = earModeByPixel.get(getConfigPixel(data, 1).rgb) ?? 'NONE' const earMode = earModeByPixel.get(getConfigPixel(data, 1).rgb) ?? 'NONE'
const earAnchor = earAnchorByPixel.get(getConfigPixel(data, 2).rgb) ?? 'CENTER' const earAnchor = earAnchorByPixel.get(getConfigPixel(data, 2).rgb) ?? 'CENTER'
const protrusions = getConfigPixel(data, 3).rgb const protrusions = protrusionsByPixel.get(getConfigPixel(data, 3).rgb) ?? 'NONE'
const claws = protrusions === MAGIC_PIXELS.GREEN || protrusions === MAGIC_PIXELS.CYAN
const horn = protrusions === MAGIC_PIXELS.PURPLE || protrusions === MAGIC_PIXELS.CYAN
const tailMode = tailModeByPixel.get(getConfigPixel(data, 4).rgb) ?? 'NONE' const tailMode = tailModeByPixel.get(getConfigPixel(data, 4).rgb) ?? 'NONE'
const tailPixel = getConfigPixel(data, 5) const tailPixel = getConfigPixel(data, 5)
const tailBends: [number, number, number, number] = [0, 0, 0, 0] const tailBends: [number, number, number, number] = [0, 0, 0, 0]
@@ -444,12 +566,13 @@ function parseV0Features(data: Uint8ClampedArray): EarsFeatures {
const etcIsDefault = etcPixel.rgb === MAGIC_PIXELS.BLUE const etcIsDefault = etcPixel.rgb === MAGIC_PIXELS.BLUE
const chestSize = etcIsDefault ? 0 : Math.min(etcPixel.r / 128, 1) const chestSize = etcIsDefault ? 0 : Math.min(etcPixel.r / 128, 1)
const capeEnabled = !etcIsDefault && (etcPixel.b & 16) !== 0 const capeEnabled = !etcIsDefault && (etcPixel.b & 16) !== 0
const bitflags = getConfigPixel(data, 12).rgb
const bitflagsAreDefault = bitflags === MAGIC_PIXELS.BLUE
return { return {
earMode, earMode,
earAnchor, earAnchor,
claws, protrusions,
horn,
tailMode, tailMode,
tailSegments, tailSegments,
tailBends, tailBends,
@@ -459,9 +582,12 @@ function parseV0Features(data: Uint8ClampedArray): EarsFeatures {
snoutDepth, snoutDepth,
chestSize, chestSize,
wingMode: wingModeByPixel.get(getConfigPixel(data, 8).rgb) ?? 'NONE', wingMode: wingModeByPixel.get(getConfigPixel(data, 8).rgb) ?? 'NONE',
animateWings: getConfigPixel(data, 9).rgb !== MAGIC_PIXELS.RED, wingAnimationMode: wingAnimationModeByPixel.get(getConfigPixel(data, 9).rgb) ?? 'NORMAL',
capeEnabled, capeEnabled,
emissive: getConfigPixel(data, 10).rgb === MAGIC_PIXELS.ORANGE, emissive: getConfigPixel(data, 10).rgb === MAGIC_PIXELS.ORANGE,
legMode: legModeByPixel.get(getConfigPixel(data, 11).rgb) ?? 'PLANTIGRADE',
animateTail: bitflagsAreDefault || (bitflags & 1) !== 0,
swapJacketBackAndTail: !bitflagsAreDefault && (bitflags & 2) !== 0,
} }
} }
@@ -567,8 +693,14 @@ function applyEraseData(data: Uint8ClampedArray, eraseData: Uint8Array | undefin
} }
} }
function forceOpaqueSkinRegions(data: Uint8ClampedArray) { function forceOpaqueSkinRegions(data: Uint8ClampedArray, legMode: LegMode) {
for (const [x1, y1, x2, y2] of FORCED_OPAQUE_REGIONS) { const regions =
legMode === 'DIGITIGRADE_FULL'
? FORCED_OPAQUE_REGIONS_WITHOUT_LEGS
: legMode === 'DIGITIGRADE_PARTIAL'
? FORCED_OPAQUE_REGIONS_WITHOUT_LEG_BOTTOM
: FORCED_OPAQUE_REGIONS
for (const [x1, y1, x2, y2] of regions) {
for (let x = x1; x < x2; x++) { for (let x = x1; x < x2; x++) {
for (let y = y1; y < y2; y++) { for (let y = y1; y < y2; y++) {
data[pixelOffset(x, y) + 3] = 255 data[pixelOffset(x, y) + 3] = 255
@@ -577,6 +709,44 @@ function forceOpaqueSkinRegions(data: Uint8ClampedArray) {
} }
} }
function displaceDigitigradeLegPixels(data: Uint8ClampedArray, legMode: LegMode) {
if (legMode === 'PLANTIGRADE') return null
const displaced = new Uint8ClampedArray(data.length)
const regions = legMode === 'DIGITIGRADE_FULL' ? LEG_REGIONS : LEG_BOTTOM_HALF_REGIONS
for (const [x1, y1, x2, y2, forceOpaque] of regions) {
for (let x = x1; x < x2; x++) {
for (let y = y1; y < y2; y++) {
const offset = pixelOffset(x, y)
displaced.set(data.subarray(offset, offset + 4), offset)
if (forceOpaque) displaced[offset + 3] = 255
data.fill(0, offset, offset + 4)
}
}
}
return displaced
}
function swapJacketBackAndTailPixels(data: Uint8ClampedArray) {
const tail = new Uint8ClampedArray(8 * 12 * 4)
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 12; y++) {
const tailOffset = pixelOffset(56 + x, 16 + y)
const savedOffset = (y * 8 + x) * 4
tail.set(data.subarray(tailOffset, tailOffset + 4), savedOffset)
const jacketOffset = pixelOffset(32 + x, 36 + (11 - y))
data.set(data.subarray(jacketOffset, jacketOffset + 4), tailOffset)
}
}
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 12; y++) {
const savedOffset = (y * 8 + x) * 4
data.set(tail.subarray(savedOffset, savedOffset + 4), pixelOffset(32 + x, 36 + y))
}
}
}
function getEmissivePalette(data: Uint8ClampedArray) { function getEmissivePalette(data: Uint8ClampedArray) {
const palette = new Set<number>() const palette = new Set<number>()
for (let x = 52; x < 56; x++) { for (let x = 52; x < 56; x++) {
@@ -857,7 +1027,22 @@ class EarsGeometryBuilder {
flip: TextureFlip = 'NONE', flip: TextureFlip = 'NONE',
grow = 0, grow = 0,
) { ) {
this.quad(u, v, width, height, rotation, flip, grow, false) this.quad(u, v, width, height, rotation, flip, grow, false, 0, 0, 0)
}
frontSkew(
u: number,
v: number,
width: number,
height: number,
xSkew: number,
ySkew: number,
zSkew: number,
rotation: TextureRotation = 'NONE',
flip: TextureFlip = 'NONE',
grow = 0,
) {
this.quad(u, v, width, height, rotation, flip, grow, false, xSkew, ySkew, zSkew)
} }
back( back(
@@ -869,7 +1054,7 @@ class EarsGeometryBuilder {
flip: TextureFlip = 'NONE', flip: TextureFlip = 'NONE',
grow = 0, grow = 0,
) { ) {
this.quad(u, v, width, height, rotation, flipHorizontally(flip), grow, true) this.quad(u, v, width, height, rotation, flipHorizontally(flip), grow, true, 0, 0, 0)
} }
doubleSided( doubleSided(
@@ -905,42 +1090,46 @@ class EarsGeometryBuilder {
flip: TextureFlip, flip: TextureFlip,
grow: number, grow: number,
back: boolean, back: boolean,
xSkew: number,
ySkew: number,
zSkew: number,
) { ) {
const anchor = this.state.anchor const anchor = this.state.anchor
const textures = this.textures.get(this.textureSource) const textures = this.textures.get(this.textureSource)
if (!anchor || !textures) return if (!anchor || !textures) return
const textureWidth = this.textureSource === 'skin' ? 64 : 20 const skinTexture = this.textureSource === 'skin' || this.textureSource === 'displaced-skin'
const textureHeight = this.textureSource === 'skin' ? 64 : 16 const textureWidth = skinTexture ? 64 : 20
const textureHeight = skinTexture ? 64 : 16
const uvs = calculateUvs(u, v, width, height, rotation, flip, textureWidth, textureHeight) const uvs = calculateUvs(u, v, width, height, rotation, flip, textureWidth, textureHeight)
const positions = back const positions = back
? [ ? [
grow + xSkew,
grow + ySkew,
-zSkew,
-width - grow + xSkew,
grow, grow,
grow, -zSkew,
0, -width - grow - xSkew,
-width - grow,
grow,
0,
-width - grow,
-height - grow, -height - grow,
0, zSkew,
grow, grow - xSkew,
-height - grow, -height - grow + ySkew,
0, zSkew,
] ]
: [ : [
grow, grow - xSkew,
-height - grow, -height - grow + ySkew,
0, zSkew,
-width - grow, -width - grow - xSkew,
-height - grow, -height - grow - ySkew,
0, zSkew,
-width - grow, -width - grow + xSkew,
grow, grow - ySkew,
0, -zSkew,
grow, grow + xSkew,
grow, grow + ySkew,
0, -zSkew,
] ]
const orderedUvs = back ? [uvs[3], uvs[2], uvs[1], uvs[0]] : [uvs[0], uvs[1], uvs[2], uvs[3]] const orderedUvs = back ? [uvs[3], uvs[2], uvs[1], uvs[0]] : [uvs[0], uvs[1], uvs[2], uvs[3]]
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
@@ -1213,18 +1402,24 @@ function renderTail(builder: EarsGeometryBuilder, features: EarsFeatures) {
builder.pop() builder.pop()
} }
function renderClawsAndHorn(builder: EarsGeometryBuilder, features: EarsFeatures, slim: boolean) { function renderProtrusions(builder: EarsGeometryBuilder, features: EarsFeatures, slim: boolean) {
if (features.claws) { if (CLAW_PROTRUSIONS.has(features.protrusions)) {
const legOffset =
features.legMode === 'DIGITIGRADE_FULL'
? -1.5
: features.legMode === 'DIGITIGRADE_PARTIAL'
? -1
: 0
builder.push() builder.push()
builder.anchorTo('LEFT_LEG') builder.anchorTo('LEFT_LEG')
builder.translate(0, 0, -4) builder.translate(0, 0, -4 + legOffset)
builder.rotate(90, 1, 0, 0) builder.rotate(90, 1, 0, 0)
builder.doubleSided(16, 48, 4, 4, 'NONE', 'HORIZONTAL') builder.doubleSided(16, 48, 4, 4, 'NONE', 'HORIZONTAL')
builder.pop() builder.pop()
builder.push() builder.push()
builder.anchorTo('RIGHT_LEG') builder.anchorTo('RIGHT_LEG')
builder.translate(0, 0, -4) builder.translate(0, 0, -4 + legOffset)
builder.rotate(90, 1, 0, 0) builder.rotate(90, 1, 0, 0)
builder.doubleSided(0, 16, 4, 4, 'NONE', 'HORIZONTAL') builder.doubleSided(0, 16, 4, 4, 'NONE', 'HORIZONTAL')
builder.pop() builder.pop()
@@ -1244,7 +1439,7 @@ function renderClawsAndHorn(builder: EarsGeometryBuilder, features: EarsFeatures
builder.pop() builder.pop()
} }
if (features.horn) { if (features.protrusions === 'HORN' || features.protrusions === 'CLAWS_AND_HORN') {
builder.push() builder.push()
builder.anchorTo('HEAD') builder.anchorTo('HEAD')
builder.translate(0, -8, 0) builder.translate(0, -8, 0)
@@ -1253,6 +1448,172 @@ function renderClawsAndHorn(builder: EarsGeometryBuilder, features: EarsFeatures
builder.doubleSided(56, 0, 8, 8) builder.doubleSided(56, 0, 8, 8)
builder.pop() builder.pop()
} }
if (features.protrusions === 'HALO' || features.protrusions === 'CLAWS_AND_HALO') {
builder.push()
builder.anchorTo('HEAD')
builder.translate(0, -12, 0)
builder.rotate(90, 1, 0, 0)
builder.doubleSided(56, 0, 8, 8)
builder.pop()
}
if (features.protrusions === 'DOUBLE_HALO' || features.protrusions === 'CLAWS_AND_DOUBLE_HALO') {
builder.push()
builder.anchorTo('HEAD')
builder.translate(0, -10, 0)
builder.rotate(90, 1, 0, 0)
for (let index = 0; index < 2; index++) {
builder.translate(4, 4, 0)
builder.rotate(45, 0, 0, 1)
builder.translate(-4, -4, 2)
builder.doubleSided(56, 0, 8, 8)
}
builder.pop()
}
}
function drawDigitigradeLeg(
builder: EarsGeometryBuilder,
u: number,
v: number,
grow: number,
bottom: boolean,
mend: boolean,
) {
const width = 4
const height = 12
const depth = 4
const doubleGrow = grow * 2
const skew = 1
const vOffset = bottom ? 10 : 4
builder.push()
builder.translate(width / 2, height / 2, depth / 2)
builder.scale(
(width + doubleGrow) / width,
(height + doubleGrow) / height,
(depth + doubleGrow) / depth,
)
builder.translate(-width / 2, -height / 2, -depth / 2)
builder.push()
builder.translate(0, bottom ? -6 : -12, 0)
if (!mend && bottom) {
builder.push()
builder.translate(0, 2, 0)
builder.frontSkew(u + 4, v + vOffset + 2, 4, 4, 0, 0, -skew)
builder.pop()
} else {
builder.frontSkew(u + 4, v + vOffset, 4, 6, 0, 0, -skew)
}
if (mend && !bottom) {
builder.push()
builder.translate(0, 6, 0)
builder.frontSkew(u + 4, v + vOffset + 6, 4, 2, 0, 0, skew)
builder.pop()
}
builder.rotate(-90, 0, 1, 0)
builder.translate(0, 0, -4)
if (!mend && bottom) {
builder.push()
builder.translate(0, 2, 0)
builder.frontSkew(u + 8, v + vOffset + 2, 4, 4, -skew, 0, 0)
builder.pop()
} else {
builder.frontSkew(u + 8, v + vOffset, 4, 6, -skew, 0, 0)
}
if (mend && !bottom) {
builder.push()
builder.translate(0, 6, 0)
builder.frontSkew(u + 8, v + vOffset + 6, 4, 2, skew, 0, 0)
builder.pop()
}
builder.rotate(-90, 0, 1, 0)
builder.translate(0, 0, -4)
if (!mend && bottom) {
builder.push()
builder.translate(0, 2, 0)
builder.frontSkew(u + 12, v + vOffset + 2, 4, 4, 0, 0, skew)
builder.pop()
} else {
builder.frontSkew(u + 12, v + vOffset, 4, 6, 0, 0, skew)
}
if (mend && !bottom) {
builder.push()
builder.translate(0, 6, 0)
builder.frontSkew(u + 12, v + vOffset + 5, 4, 2, 0, 0, -skew)
builder.pop()
}
builder.rotate(-90, 0, 1, 0)
builder.translate(0, 0, -4)
if (!mend && bottom) {
builder.push()
builder.translate(0, 2, 0)
builder.frontSkew(u, v + vOffset + 2, 4, 4, skew, 0, 0)
builder.pop()
} else {
builder.frontSkew(u, v + vOffset, 4, 6, skew, 0, 0)
}
if (mend && !bottom) {
builder.push()
builder.translate(0, 6, 0)
builder.frontSkew(u, v + vOffset + 6, 4, 2, -skew, 0, 0)
builder.pop()
}
builder.pop()
builder.push()
builder.rotate(90, 1, 0, 0)
if (bottom) {
builder.push()
builder.translate(0, -skew, 0)
builder.front(u + 8, v, 4, 4, 'NONE', 'VERTICAL')
builder.pop()
} else {
builder.push()
builder.translate(0, skew, 12)
builder.back(u + 8, v, 4, 4, 'NONE', 'VERTICAL')
builder.pop()
}
if (mend && bottom) {
builder.translate(0, 0, 6)
builder.front(u + 4, v + vOffset, 4, 1)
builder.translate(0, 4, 0)
builder.back(u + 12, v + vOffset, 4, 1)
}
builder.pop()
builder.pop()
}
function renderDigitigradeLegs(builder: EarsGeometryBuilder, features: EarsFeatures) {
if (features.legMode === 'PLANTIGRADE') return
const full = features.legMode === 'DIGITIGRADE_FULL'
builder.bind('displaced-skin')
for (let layer = 0; layer < 2; layer++) {
const grow = layer === 0 ? 0 : 0.25
builder.push()
builder.anchorTo('LEFT_LEG')
if (full) builder.translate(0, 0, -0.5)
if (layer === 1) builder.translate(0, 0.5, 0)
if (full) drawDigitigradeLeg(builder, layer === 0 ? 16 : 0, 48, grow, false, true)
drawDigitigradeLeg(builder, layer === 0 ? 16 : 0, 48, grow, true, !full)
builder.pop()
builder.push()
builder.anchorTo('RIGHT_LEG')
if (full) builder.translate(0, 0, -0.5)
if (layer === 1) builder.translate(0, 0.5, 0)
if (full) drawDigitigradeLeg(builder, 0, layer === 0 ? 16 : 32, grow, false, true)
drawDigitigradeLeg(builder, 0, layer === 0 ? 16 : 32, grow, true, !full)
builder.pop()
}
builder.bind('skin')
} }
function renderSnout(builder: EarsGeometryBuilder, features: EarsFeatures) { function renderSnout(builder: EarsGeometryBuilder, features: EarsFeatures) {
@@ -1366,7 +1727,7 @@ function renderWings(builder: EarsGeometryBuilder, features: EarsFeatures) {
const mode = features.wingMode const mode = features.wingMode
if (mode === 'NONE') return if (mode === 'NONE') return
const wiggle = features.animateWings ? Math.sin(8 / 12) * 2 : 0 const wiggle = features.wingAnimationMode !== 'NONE' ? Math.sin(8 / 12) * 2 : 0
builder.push() builder.push()
builder.anchorTo('TORSO') builder.anchorTo('TORSO')
builder.bind('wing') builder.bind('wing')
@@ -1604,7 +1965,9 @@ export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture
const alfalfa = parseAlfalfa(sourceImage.data) const alfalfa = parseAlfalfa(sourceImage.data)
const processedData = new Uint8ClampedArray(sourceImage.data) const processedData = new Uint8ClampedArray(sourceImage.data)
applyEraseData(processedData, alfalfa.get('erase')) applyEraseData(processedData, alfalfa.get('erase'))
forceOpaqueSkinRegions(processedData) const displacedData = displaceDigitigradeLegPixels(processedData, features.legMode)
if (features.swapJacketBackAndTail) swapJacketBackAndTailPixels(processedData)
forceOpaqueSkinRegions(processedData, features.legMode)
const palette = features.emissive ? getEmissivePalette(processedData) : new Set<number>() const palette = features.emissive ? getEmissivePalette(processedData) : new Set<number>()
if (palette.size === 0) features.emissive = false if (palette.size === 0) features.emissive = false
@@ -1623,6 +1986,20 @@ export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture
if (skinTextures.emissive) addEmissiveBodyOverlays(model, skinTextures.emissive, resources) if (skinTextures.emissive) addEmissiveBodyOverlays(model, skinTextures.emissive, resources)
const textures = new Map<TextureSource, TexturePair>([['skin', skinTextures]]) const textures = new Map<TextureSource, TexturePair>([['skin', skinTextures]])
if (displacedData) {
const displacedPixels = features.emissive
? splitEmissivePixels(displacedData, palette)
: { base: displacedData, emissive: undefined }
const displacedTextures: TexturePair = {
base: createCanvasTexture(displacedPixels.base, SKIN_SIZE, SKIN_SIZE),
emissive: displacedPixels.emissive
? createCanvasTexture(displacedPixels.emissive, SKIN_SIZE, SKIN_SIZE)
: undefined,
}
resources.featureTextures.add(displacedTextures.base)
if (displacedTextures.emissive) resources.featureTextures.add(displacedTextures.emissive)
textures.set('displaced-skin', displacedTextures)
}
const wingData = alfalfa.get('wing') const wingData = alfalfa.get('wing')
const wingDimensions = wingData ? readPngDimensions(wingData) : null const wingDimensions = wingData ? readPngDimensions(wingData) : null
if ( if (
@@ -1659,10 +2036,11 @@ export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture
const builder = new EarsGeometryBuilder(anchors, textures, resources) const builder = new EarsGeometryBuilder(anchors, textures, resources)
renderEars(builder, features) renderEars(builder, features)
renderTail(builder, features) renderTail(builder, features)
renderClawsAndHorn(builder, features, isSlimModel(model)) renderProtrusions(builder, features, isSlimModel(model))
renderSnout(builder, features) renderSnout(builder, features)
renderChest(builder, features) renderChest(builder, features)
renderWings(builder, features) renderWings(builder, features)
renderCape(builder, features) renderCape(builder, features)
renderDigitigradeLegs(builder, features)
return true return true
} }