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
15 changes: 13 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,20 @@ testcore:
# machine has less RAM/GPU headroom.
WORKERS ?= 4

# Honor UNTOLD_PYTHON so the interpreter we install into matches the one
# BaseRenderSetup actually shells out to at test time (default: python3).
TESTRENDERER_PYTHON ?= $(if $(UNTOLD_PYTHON),$(UNTOLD_PYTHON),python3)

# --break-system-packages only exists in pip 23.0.1+. Xcode's bundled Python
# (pip 21.2.4 as of this writing) rejects it as an unknown option, which aborts
# the target before it can even upgrade pip. Detect support instead of
# hardcoding the flag so Homebrew/python.org pips (which need it under PEP 668)
# keep working while older pips fall back to an unflagged install.
PIP_BREAK_FLAG := $(shell $(TESTRENDERER_PYTHON) -m pip install --help 2>/dev/null | grep -q -- --break-system-packages && echo --break-system-packages)

testrenderer:
python3 -m pip install --user --break-system-packages --upgrade pip wheel setuptools
python3 -m pip install --user --break-system-packages opencv-python-headless scikit-image
$(TESTRENDERER_PYTHON) -m pip install --user $(PIP_BREAK_FLAG) --upgrade pip wheel setuptools
$(TESTRENDERER_PYTHON) -m pip install --user $(PIP_BREAK_FLAG) opencv-python-headless scikit-image
UNTOLD_KEEP_ARTIFACTS=$(KEEP) swift test --parallel --num-workers $(WORKERS) --filter UntoldEngineRenderTests

# AsyncMeshLoadingTest/AssetLoadingGateRenderingTests only — the two classes CI runs
Expand Down
23 changes: 22 additions & 1 deletion Sources/UntoldEngine/Animation/ClipSampler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@

import simd

/// Scales `time` by `speed` and either wraps it by `duration` (repeating
/// clips) or clamps it to `[0, duration]` (one-shot clips), so a completed
/// one-shot holds its last pose instead of jumping back to pose zero on the
/// next clip-duration boundary. Shared by `ClipSampler.sample` and
/// `applyRootMotion` so the two stay in lockstep.
///
/// Classifies the whole clip from its first animated channel's `repeats`
/// flag. `Animation.repeatAnimation` is never set per-channel anywhere in
/// the engine — `restartAnimation` is the only mutator and it always writes
/// every joint of a clip to the same value — so every animated channel is
/// expected to agree; a debug-build assert catches the invariant breaking
/// rather than silently mis-sampling the channels that disagree.
func clipLevelChannelTime(time: Float, duration: Float, speed: Float, channels: [CompiledAnimationClip.Channel]) -> Float {
let repeats = channels.first(where: \.animated)?.repeats ?? true
assert(
channels.allSatisfy { !$0.animated || $0.repeats == repeats },
"clipLevelChannelTime assumes every animated channel in a clip shares the same repeat flag"
)
return (repeats ? fmod(time, duration) : min(max(time, 0), duration)) * speed
}

/// Samples a `CompiledAnimationClip` into a `PoseBuffer` without allocating.
///
/// Keyframe intervals are located with a binary search seeded by a per-joint
Expand Down Expand Up @@ -44,7 +65,7 @@ struct ClipSampler {
bind(clip)
pose.resize(jointCount: clip.jointCount)

let channelTime = fmod(time, duration) * speed
let channelTime = clipLevelChannelTime(time: time, duration: duration, speed: speed, channels: clip.channels)

for index in 0 ..< clip.jointCount {
let channel = clip.channels[index]
Expand Down
4 changes: 3 additions & 1 deletion Sources/UntoldEngine/Animation/RootMotion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ func applyRootMotion(
// wrap, to detect when the clip looped between frames. Non-repeating
// channels clamp at their last key (like the sampler), so a one-shot
// clip never fakes a wrap and never injects a per-loop correction.
let channelTime = fmod(animationComponent.currentTime, clipDuration) * clipSpeed
let channelTime = clipLevelChannelTime(
time: animationComponent.currentTime, duration: clipDuration, speed: clipSpeed, channels: compiledClip.channels
)
let translationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.translationTimes.last, repeats: channel.repeats)
let rotationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.rotationTimes.last, repeats: channel.repeats)

Expand Down
19 changes: 18 additions & 1 deletion Sources/UntoldEngine/ECS/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -872,7 +872,11 @@ protocol LODDistanceLevel {
public struct LODLevel {
public var mesh: [Mesh] // Meshes for this lod
public var maxDistance: Float // Switch to next LOD beyond this
public var screenPercentage: Float // Optional: screen-space threshold
/// The size on screen at which this level is detailed enough to take over from the
/// one before it: the share of the viewport height (1 is the whole height) that
/// the sphere around the entity covers. Read when the component selects by screen
/// size (`LODComponent.selectsByScreenSize`); 0 leaves the switch to `maxDistance`.
public var screenPercentage: Float
public var url: URL? // URL to the LOD file (for streaming reload)
public var assetName: String? // Mesh name within the file (for streaming reload)
public var residencyState: LODResidencyState = .unknown // Streaming state
Expand Down Expand Up @@ -918,6 +922,19 @@ public class LODComponent: Component {
/// one level is drawn stays when another takes its place.
public var levelsShareMaterials: Bool = false

/// True when the levels are chosen by the size of the entity on screen instead of
/// by distances fixed beforehand: a level takes over when the sphere around the
/// entity covers no more than the level's `screenPercentage`. The size follows the
/// entity's scale and the field of view, so the switch stays where the entity looks
/// as large as when the level was made for it. A level without a screen size, and
/// any view without perspective, falls back to `maxDistance`.
public var selectsByScreenSize: Bool = false

/// The radius of that sphere in the entity's own space. 0 measures the entity's
/// bounding box. A part of a larger model carries the radius of the whole model, so
/// that all its parts change level together.
public var screenSizeRadius: Float = 0

public required init() {}

/// Check if the desired LOD level has a resident mesh
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ private func drawModelSurfaceMeshes(
uniforms.normalMatrix = normalMatrix
uniforms.viewMatrix = viewMatrix
uniforms.modelMatrix = modelMatrix
uniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition)
uniforms.cameraPosition = shadingCameraPosition(cameraComponent)
uniforms.projectionMatrix = renderInfo.perspectiveSpace

renderEncoder.setVertexBytes(
Expand Down
Loading
Loading