Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ The changelog is release-oriented and uses a small fixed set of categories:

### Added

- Immutable compiled YCD animation samplers retain owned channel data for repeated native evaluation without stale caches after source edits.
- Per-track quaternion encoding overrides allow explicit facial rotations while keeping body and camera packing unchanged.

### Fixed

- Explicit quaternion authoring stores all four static components instead of discarding W and reconstructing it from rounded XYZ values.

### Performance

- Up to 14x faster repeated YCD sampling with compiled animation samplers, keeping typed outputs and cached-quaternion interpolation order.
- YCD precision diagnostics distinguish integer-frame and subframe component errors, report their frame locations and include quaternion angular error independently of component limits.
- YCD precision limits cover scalar, vector and quaternion components at subframes, including physical sequence overlaps; component-only policies no longer omit between-frame quaternion checks.
- Facial translation, rotation and scale honor the RETAIL channel policy with 16-bit compression, preserving static channels and per-track RAW_FLOAT overrides.
Expand Down
2 changes: 2 additions & 0 deletions fivefury/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,7 @@
YCD_VERSION,
Ycd,
YcdAnimation,
YcdAnimationSampler,
YcdAnimationBoneId,
YcdAnimationTrack,
YcdAnimSequence,
Expand Down Expand Up @@ -2025,6 +2026,7 @@
"Ycd",
"YcdAnimSequence",
"YcdAnimation",
"YcdAnimationSampler",
"YcdAnimationBoneId",
"YcdAnimationTrack",
"YcdCameraAnimationSample",
Expand Down
2 changes: 2 additions & 0 deletions fivefury/ycd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
get_ycd_track_format,
parse_ycd_uv_clip_binding,
)
from .playback import YcdAnimationSampler
from .reader import read_ycd, read_ycd_embedded_resource
from .runtime_headers import (
GEN9_YCD_RUNTIME_PROFILE,
Expand All @@ -73,6 +74,7 @@
"Ycd",
"YcdAnimSequence",
"YcdAnimation",
"YcdAnimationSampler",
"YcdAnimationBoneId",
"YcdAnimationTrack",
"YcdCameraAnimationSample",
Expand Down
38 changes: 38 additions & 0 deletions fivefury/ycd/channel_samples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

import numpy as np

from .sequence_channels import (
YcdAnimChannel,
YcdCachedQuaternionChannel,
YcdIndirectQuantizeFloatChannel,
YcdLinearFloatChannel,
YcdQuantizeFloatChannel,
YcdRawFloatChannel,
)


def decoded_channel_components(channel: YcdAnimChannel) -> np.ndarray:
"""Decoded component cycles; no quantization or interpolation is repeated."""
if isinstance(channel, YcdCachedQuaternionChannel):
raise TypeError("Cached quaternion opcodes require a sequence context")
if isinstance(channel, YcdIndirectQuantizeFloatChannel):
if not len(channel.frames) or not len(channel.values):
return np.array([[channel.offset]], dtype=np.float64)
indices = np.asarray(channel.frames, dtype=np.int64)
values = np.asarray(channel.values, dtype=np.float64)
result = np.full(len(indices), channel.offset, dtype=np.float64)
valid = indices < len(values)
result[valid] = values[indices[valid]]
return result[:, None]
if isinstance(
channel, (YcdRawFloatChannel, YcdQuantizeFloatChannel, YcdLinearFloatChannel)
):
values = np.asarray(channel.values, dtype=np.float64)
if not len(values):
values = np.array(
[0.0 if isinstance(channel, YcdRawFloatChannel) else channel.offset],
dtype=np.float64,
)
return values.reshape(-1, 1)
return np.asarray(channel.evaluate_components(0), dtype=np.float64).reshape(1, -1)
29 changes: 5 additions & 24 deletions fivefury/ycd/channel_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,12 @@
AuthoringStage,
ValidationReport,
)
from .channel_samples import decoded_channel_components
from .reader import read_ycd
from .sequence_channels import (
YcdAnimSequence,
YcdCachedQuaternionChannel,
YcdChannelType,
YcdIndirectQuantizeFloatChannel,
YcdLinearFloatChannel,
YcdQuantizeFloatChannel,
YcdRawFloatChannel,
)
from .write import build_ycd_bytes

Expand All @@ -43,26 +40,10 @@ def _packed_samples(sequence: YcdAnimSequence, count: int) -> tuple[np.ndarray,
width = channel.component_count
if component + width > 4:
raise ValueError("YCD sequence exceeds four stored components")
if isinstance(channel, YcdIndirectQuantizeFloatChannel):
if channel.frames and len(channel.values):
indices = np.resize(np.asarray(channel.frames, dtype=np.int64), count)
values = np.asarray(channel.values, dtype=np.float64)
samples[:, component] = values[indices]
else:
samples[:, component] = channel.offset
elif isinstance(
channel,
(YcdRawFloatChannel, YcdQuantizeFloatChannel, YcdLinearFloatChannel),
):
values = np.asarray(channel.values, dtype=np.float64)
if len(values):
samples[:, component] = (
values if len(values) == count else np.resize(values, count)
)
else:
samples[:, component] = channel.evaluate_float(0)
else:
samples[:, component : component + width] = channel.evaluate_components(0)
values = decoded_channel_components(channel)
samples[:, component : component + width] = (
values if len(values) in (1, count) else np.resize(values, (count, width))
)
component += width
return samples, layout

Expand Down
7 changes: 7 additions & 0 deletions fivefury/ycd/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

if TYPE_CHECKING:
from ..authoring.context import BuildContext
from .playback import YcdAnimationSampler

YCD_UV_CLIP_MARKER = "_uv_"
YCD_UV_UNKNOWN1C = 0x6B002400
Expand Down Expand Up @@ -546,6 +547,12 @@ def get_frame_position(self, frame: float) -> YcdFramePosition:
frame0=frame0, frame1=frame1, alpha0=alpha0, alpha1=alpha1
)

def compile(self) -> YcdAnimationSampler:
"""Snapshot channels for repeated, stateless native playback."""
from .playback import YcdAnimationSampler

return YcdAnimationSampler(self)

def evaluate_tracks(
self,
frame: float,
Expand Down
116 changes: 116 additions & 0 deletions fivefury/ycd/playback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from __future__ import annotations

import math
from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np

from .._native import _ffi
from ..vector import Quaternion, Vector4
from .channel_samples import decoded_channel_components
from .sequence_channels import YcdCachedQuaternionChannel, YcdChannelType
from .sequence_tracks import YcdAnimationTrack, is_ycd_rotation_track

if TYPE_CHECKING:
from .model import YcdAnimation


def _sequence_snapshot(sequence):
lanes = []
layout = -1
for channel in sequence.channels:
if isinstance(channel, YcdCachedQuaternionChannel):
if not sequence.is_cached_quaternion:
raise ValueError(
"Compile requires cached quaternion opcodes to have a cached sequence layout"
)
layout = (
-2
if channel.channel_type is YcdChannelType.CACHED_QUATERNION2
else int(channel.quat_index)
)
elif len(lanes) < 4:
components = decoded_channel_components(channel)
lanes.extend(
np.ascontiguousarray(values)
for values in components.T[: 4 - len(lanes)]
)
if sequence.is_cached_quaternion and layout == -1:
raise ValueError(
"Cached quaternion sequence is missing its reconstruction channel"
)
if layout >= 0 and (layout > 3 or len(lanes) != 3):
raise ValueError(
"Cached quaternion reconstruction requires three components and a valid omitted index"
)
if layout == -2 and len(lanes) != 4:
raise ValueError("Cached quaternion normalization requires four components")
return layout, tuple(lanes)


@dataclass(frozen=True, slots=True, init=False)
class YcdAnimationSampler:
"""An owned, immutable snapshot. Recompile explicitly after source edits."""

frames: int
sequence_frame_limit: int
sample_bytes: int
_plan: object

def __init__(self, animation: YcdAnimation):
keys = dict.fromkeys(
(int(s.bone_id.bone_id), int(s.bone_id.track))
for block in animation.sequences
for s in block.anim_sequences
if s.bone_id is not None
)
index = {key: i for i, key in enumerate(keys)}
blocks = []
sample_bytes = 0
for block in animation.sequences:
entries = []
for sequence in block.anim_sequences:
if sequence.bone_id is None:
continue
key = (int(sequence.bone_id.bone_id), int(sequence.bone_id.track))
layout, lanes = _sequence_snapshot(sequence)
if layout != -1 and not is_ycd_rotation_track(key[1]):
raise ValueError(
"Cached quaternion layout requires a rotation track"
)
entries.append((index[key], layout, lanes))
sample_bytes += sum(lane.nbytes for lane in lanes)
blocks.append(tuple(entries))
plan = _ffi.ycd_playback_compile(
tuple(blocks),
tuple(keys),
tuple(is_ycd_rotation_track(k[1]) for k in keys),
Vector4,
Quaternion,
)
object.__setattr__(self, "frames", int(animation.frames))
object.__setattr__(
self, "sequence_frame_limit", max(int(animation.sequence_frame_limit), 1)
)
object.__setattr__(self, "sample_bytes", sample_bytes)
object.__setattr__(self, "_plan", plan)

def evaluate_tracks(
self,
frame: float,
*,
track: int | YcdAnimationTrack | None = None,
interpolate: bool = True,
) -> dict[tuple[int, int], Vector4 | Quaternion]:
value = float(frame)
if not math.isfinite(value):
raise ValueError("YCD sample frame must be finite")
return _ffi.ycd_playback_evaluate(
self._plan,
value,
not interpolate or isinstance(frame, int),
self.frames,
self.sequence_frame_limit,
None if track is None else int(track),
)
2 changes: 2 additions & 0 deletions native/animation/bindings.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ PyObject* mod_ycd_track_sampler_new(PyObject*, PyObject* args);
PyObject* mod_ycd_track_sampler_window(PyObject*, PyObject* args);
PyObject* mod_ycd_track_sampler_retained_count(PyObject*, PyObject* args);
PyObject* mod_ycd_compare_samples(PyObject*, PyObject* args);
PyObject* mod_ycd_playback_compile(PyObject*, PyObject* args);
PyObject* mod_ycd_playback_evaluate(PyObject*, PyObject* args);

} // namespace fivefury_py
Loading