feat(vfx): bind effects to live animated poses
This commit is contained in:
parent
96ddfdf175
commit
542dcfc384
41 changed files with 3246 additions and 741 deletions
|
|
@ -1,191 +1,177 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAnimationHookSink"/> that translates particle-bearing
|
||||
/// animation hooks into <see cref="ParticleSystem"/> spawn / stop calls.
|
||||
///
|
||||
/// <para>
|
||||
/// Hook types handled (r04 §6):
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <see cref="CreateParticleHook"/> — spawn an emitter from the
|
||||
/// hook's <c>EmitterInfoId</c> at the entity's world pose plus the
|
||||
/// hook offset. Retail attaches to a specific mesh part; we
|
||||
/// attach to the entity's root and will refine per-part when the
|
||||
/// renderer exposes per-part world transforms.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="CreateBlockingParticleHook"/> — has the same payload
|
||||
/// as CreateParticleHook, but suppresses creation while the same nonzero
|
||||
/// logical emitter ID is live. It does not pause animation. Production DAT
|
||||
/// loading substitutes <see cref="RetailCreateBlockingParticleHook"/> so
|
||||
/// the inherited payload is retained despite the dependency's header-only
|
||||
/// model. Suppression itself lands with live emitter bindings in Step 5.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="DestroyParticleHook"/> — stop the most-recent emitter
|
||||
/// matching the hook's <c>EmitterId</c>. Deferred to a future pass
|
||||
/// when we retain per-entity emitter-id → handle maps.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="StopParticleHook"/> — pause all emitters on the
|
||||
/// entity (fade out).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="DefaultScriptHook"/> / <see cref="DefaultScriptPartHook"/>
|
||||
/// — trigger the entity's <c>DefaultScriptId</c> PhysicsScript.
|
||||
/// Requires PhysicsScript table; deferred.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="CallPESHook"/> — fire a PhysicsScript by id.
|
||||
/// Deferred until DRW exposes PhysicsScript dat.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Per-entity emitter handle tracking is kept here so DestroyParticle /
|
||||
/// StopParticle can target the right emitter when a server-sent
|
||||
/// <c>PlayEffect</c> fires.
|
||||
/// </para>
|
||||
/// Routes particle animation hooks through the owner's current root/part pose
|
||||
/// and retains the retail logical-emitter identity rules.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Retail oracle: <c>CreateParticleHook::Execute</c> (<c>0x00526EC0</c>),
|
||||
/// <c>CreateBlockingParticleHook::Execute</c> (<c>0x00526EF0</c>),
|
||||
/// <c>ParticleManager::CreateParticleEmitter</c> (<c>0x0051B6C0</c>), and
|
||||
/// <c>CreateBlockingParticleEmitter</c> (<c>0x0051B8A0</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The complete hook offset frame is retained on each binding for schema
|
||||
/// fidelity. Retail <c>Particle::Init</c> (<c>0x0051C930</c>) transforms the
|
||||
/// offset origin through the current owner frame, but does not apply the hook
|
||||
/// offset quaternion to particle vectors; emitter orientation is the current
|
||||
/// root/part orientation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ParticleHookSink : IAnimationHookSink
|
||||
{
|
||||
private readonly ParticleSystem _system;
|
||||
|
||||
// entityId → most-recently-spawned emitter handle per emitterId.
|
||||
// DestroyParticleHook.EmitterId is effectively an application-layer
|
||||
// key ("the smoke trail I spawned 2 seconds ago"), so we track by
|
||||
// (entity, emitterId).
|
||||
private readonly IEntityEffectPoseSource _poses;
|
||||
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
|
||||
// entityId → set of live emitter handles. Dictionary-as-set so we can
|
||||
// remove individual handles when their emitter dies (M4 fix —
|
||||
// ConcurrentBag couldn't drop entries, so handles for naturally-expired
|
||||
// emitters used to leak).
|
||||
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
|
||||
// Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied.
|
||||
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
|
||||
private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new();
|
||||
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
|
||||
private readonly ConcurrentDictionary<uint, Quaternion> _rotationByEntity = new();
|
||||
// C.1.5b #56: per-entity static part transforms (PlacementFrames[Resting]
|
||||
// baked into a Matrix4x4 per Setup part). When set, SpawnFromHook applies
|
||||
// partTransforms[hook.PartIndex] to the hook offset BEFORE rotating to
|
||||
// world space. Without this, every emitter in a multi-part Setup
|
||||
// collapses to the entity root (the bug). Cleared by StopAllForEntity.
|
||||
// For ANIMATED entities this map would need a per-tick refresh similar
|
||||
// to UpdateEntityAnchor — deferred to a future phase.
|
||||
private readonly ConcurrentDictionary<uint, IReadOnlyList<Matrix4x4>> _partTransformsByEntity = new();
|
||||
private int _anonymousEmitterSerial;
|
||||
|
||||
public ParticleHookSink(ParticleSystem system)
|
||||
private readonly ConcurrentDictionary<uint, byte> _hiddenPresentationOwners = new();
|
||||
public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses)
|
||||
{
|
||||
_system = system ?? throw new ArgumentNullException(nameof(system));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_system.EmitterDied += OnEmitterDied;
|
||||
}
|
||||
|
||||
public Action<string>? DiagnosticSink { get; set; }
|
||||
|
||||
private void OnEmitterDied(int handle)
|
||||
{
|
||||
if (!_trackingByHandle.TryRemove(handle, out var t))
|
||||
if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding))
|
||||
return;
|
||||
_handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
|
||||
if (_handlesByEntity.TryGetValue(t.EntityId, out var bag))
|
||||
bag.TryRemove(handle, out _);
|
||||
|
||||
if (binding.LogicalId != 0
|
||||
&& _handlesByKey.TryGetValue((binding.OwnerLocalId, binding.LogicalId), out int current)
|
||||
&& current == handle)
|
||||
{
|
||||
_handlesByKey.TryRemove((binding.OwnerLocalId, binding.LogicalId), out _);
|
||||
}
|
||||
if (_handlesByEntity.TryGetValue(binding.OwnerLocalId, out var handles))
|
||||
{
|
||||
handles.TryRemove(handle, out _);
|
||||
if (handles.IsEmpty)
|
||||
_handlesByEntity.TryRemove(binding.OwnerLocalId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
switch (hook)
|
||||
{
|
||||
case RetailCreateBlockingParticleHook:
|
||||
// The centralized content reader now preserves the complete
|
||||
// retail payload. Step 5 owns live logical-ID suppression and
|
||||
// attachment semantics; do not silently treat blocking as a
|
||||
// normal replacement before that mechanism lands.
|
||||
case RetailCreateBlockingParticleHook blocking:
|
||||
SpawnFromHook(
|
||||
entityId,
|
||||
(uint)blocking.EmitterInfoId,
|
||||
blocking.Offset,
|
||||
unchecked((int)blocking.PartIndex),
|
||||
blocking.EmitterId,
|
||||
isBlocking: true);
|
||||
break;
|
||||
|
||||
case CreateParticleHook cph:
|
||||
SpawnFromHook(entityId, entityWorldPosition,
|
||||
emitterInfoId: (uint)cph.EmitterInfoId,
|
||||
offset: cph.Offset.Origin,
|
||||
partIndex: (int)cph.PartIndex,
|
||||
logicalId: cph.EmitterId);
|
||||
case CreateParticleHook create:
|
||||
SpawnFromHook(
|
||||
entityId,
|
||||
(uint)create.EmitterInfoId,
|
||||
create.Offset,
|
||||
unchecked((int)create.PartIndex),
|
||||
create.EmitterId,
|
||||
isBlocking: false);
|
||||
break;
|
||||
|
||||
case CreateBlockingParticleHook:
|
||||
// Defensive package-decoder shape. Production raw loaders
|
||||
// replace this header-only model with the retail subclass.
|
||||
// The upstream package exposes only the common header for this
|
||||
// type. Production loaders replace it with the retail payload
|
||||
// above; a header-only instance cannot create an emitter.
|
||||
DiagnosticSink?.Invoke(
|
||||
$"CreateBlockingParticle for owner 0x{entityId:X8} has no retail payload.");
|
||||
break;
|
||||
|
||||
case DestroyParticleHook dph:
|
||||
if (_handlesByKey.TryRemove((entityId, dph.EmitterId), out var handleToDestroy))
|
||||
_system.StopEmitter(handleToDestroy, fadeOut: false);
|
||||
case DestroyParticleHook destroy:
|
||||
DestroyLogical(entityId, destroy.EmitterId, fadeOut: false);
|
||||
break;
|
||||
|
||||
case StopParticleHook sph:
|
||||
if (_handlesByKey.TryGetValue((entityId, sph.EmitterId), out var handleToStop))
|
||||
_system.StopEmitter(handleToStop, fadeOut: true);
|
||||
case StopParticleHook stop:
|
||||
DestroyLogical(entityId, stop.EmitterId, fadeOut: true);
|
||||
break;
|
||||
|
||||
// DefaultScript / CallPES are routed by EntityEffectController.
|
||||
// ParticleHookSink intentionally owns particles only.
|
||||
}
|
||||
}
|
||||
|
||||
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
|
||||
=> _renderPassByEntity[entityId] = renderPass;
|
||||
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) =>
|
||||
_renderPassByEntity[entityId] = renderPass;
|
||||
|
||||
public void SetEntityRotation(uint entityId, Quaternion rotation)
|
||||
=> _rotationByEntity[entityId] = rotation;
|
||||
public void ClearEntityRenderPass(uint entityId) =>
|
||||
_renderPassByEntity.TryRemove(entityId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Register per-part static transforms for an entity. The caller
|
||||
/// (typically <c>EntityScriptActivator</c>) precomputes one
|
||||
/// <see cref="Matrix4x4"/> per Setup part using
|
||||
/// <c>SetupPartTransforms.Compute</c> and pushes them here at spawn
|
||||
/// time. <see cref="SpawnFromHook"/> applies
|
||||
/// <c>partTransforms[hook.PartIndex]</c> to the hook offset BEFORE
|
||||
/// transforming to world space. Cleared on
|
||||
/// <see cref="StopAllForEntity"/>.
|
||||
/// Mirrors live cell membership without ending emitter lifetime. Retail
|
||||
/// pauses the owner's particle manager while cell-less, then resumes the
|
||||
/// same particles and logical IDs without catch-up emission on re-entry.
|
||||
/// </summary>
|
||||
public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)
|
||||
=> _partTransformsByEntity[entityId] = partTransforms;
|
||||
|
||||
public void ClearEntityRenderPass(uint entityId)
|
||||
=> _renderPassByEntity.TryRemove(entityId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Refresh every live emitter on this entity to a new world anchor +
|
||||
/// rotation. The owning subsystem (sky-PES driver, animation tick)
|
||||
/// drives this each frame for AttachLocal emitters so they track their
|
||||
/// moving parent — retail-faithful via
|
||||
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c>, which
|
||||
/// re-reads the parent frame each tick when <c>is_parent_local != 0</c>.
|
||||
/// Safe to call for entities with no live emitters (no-op).
|
||||
/// </summary>
|
||||
public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
|
||||
public void SetEntityPresentationVisible(uint entityId, bool visible)
|
||||
{
|
||||
_rotationByEntity[entityId] = rotation;
|
||||
if (!_handlesByEntity.TryGetValue(entityId, out var bag))
|
||||
return;
|
||||
foreach (var handle in bag.Keys)
|
||||
_system.UpdateEmitterAnchor(handle, anchor, rotation);
|
||||
if (visible)
|
||||
_hiddenPresentationOwners.TryRemove(entityId, out _);
|
||||
else
|
||||
_hiddenPresentationOwners[entityId] = 0;
|
||||
|
||||
// Withdrawal is immediate. Re-entry is enabled by the next pose
|
||||
// refresh so an attached owner cannot flash for one frame at its old
|
||||
// anchor before the composed child pose is published.
|
||||
if (!visible && _handlesByEntity.TryGetValue(entityId, out var handles))
|
||||
{
|
||||
foreach (int handle in handles.Keys)
|
||||
{
|
||||
_system.SetEmitterPresentationVisible(handle, false);
|
||||
_system.SetEmitterSimulationEnabled(handle, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes every emitter from the current root/part pose. World-released
|
||||
/// particles keep their stored emission origin; AttachLocal particles read
|
||||
/// the refreshed anchor when the particle system advances.
|
||||
/// </summary>
|
||||
public void RefreshAttachedEmitters()
|
||||
{
|
||||
foreach ((int handle, EmitterBinding binding) in _bindingsByHandle)
|
||||
{
|
||||
bool presentationVisible =
|
||||
!_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId);
|
||||
if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex,
|
||||
binding.HookOffsetOrigin, out Vector3 anchor, out Quaternion rotation))
|
||||
{
|
||||
_system.UpdateEmitterAnchor(handle, anchor, rotation);
|
||||
// Keep the anchor current while spatially paused; re-entry
|
||||
// resumes at the authoritative pose without generating the
|
||||
// absent interval's time- or distance-driven emissions.
|
||||
_system.SetEmitterPresentationVisible(handle, presentationVisible);
|
||||
_system.SetEmitterSimulationEnabled(handle, presentationVisible);
|
||||
}
|
||||
else
|
||||
_system.SetEmitterPresentationVisible(handle, false);
|
||||
}
|
||||
}
|
||||
|
||||
public void StopAllForEntity(uint entityId, bool fadeOut)
|
||||
{
|
||||
if (_handlesByEntity.TryRemove(entityId, out var handles))
|
||||
{
|
||||
foreach (var handle in handles.Keys)
|
||||
foreach (int handle in handles.Keys)
|
||||
{
|
||||
// A fading emitter needs its clock to retire naturally. A
|
||||
// hard destroy is removed synchronously by ParticleSystem.
|
||||
if (fadeOut)
|
||||
_system.SetEmitterSimulationEnabled(handle, true);
|
||||
_system.StopEmitter(handle, fadeOut);
|
||||
_trackingByHandle.TryRemove(handle, out _);
|
||||
_bindingsByHandle.TryRemove(handle, out _);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,61 +180,138 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
|||
if (key.EntityId == entityId)
|
||||
_handlesByKey.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
ClearEntityRenderPass(entityId);
|
||||
_rotationByEntity.TryRemove(entityId, out _);
|
||||
_partTransformsByEntity.TryRemove(entityId, out _);
|
||||
_hiddenPresentationOwners.TryRemove(entityId, out _);
|
||||
}
|
||||
|
||||
private void DestroyLogical(uint ownerLocalId, uint logicalId, bool fadeOut)
|
||||
{
|
||||
if (logicalId == 0
|
||||
|| !_handlesByKey.TryGetValue((ownerLocalId, logicalId), out int handle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Retail StopParticleEmitter (0x0051B7B0) only marks the emitter as
|
||||
// stopped; it remains in particle_table until its final particle dies.
|
||||
// A blocking create with the same logical ID must therefore continue
|
||||
// to see it. DestroyParticleEmitter (0x0051B770) removes it at once.
|
||||
if (!fadeOut)
|
||||
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
|
||||
if (fadeOut)
|
||||
_system.SetEmitterSimulationEnabled(handle, true);
|
||||
_system.StopEmitter(handle, fadeOut);
|
||||
}
|
||||
|
||||
private void SpawnFromHook(
|
||||
uint entityId,
|
||||
Vector3 worldPos,
|
||||
uint ownerLocalId,
|
||||
uint emitterInfoId,
|
||||
Vector3 offset,
|
||||
Frame offset,
|
||||
int partIndex,
|
||||
uint logicalId)
|
||||
uint logicalId,
|
||||
bool isBlocking)
|
||||
{
|
||||
// Spawn position: entity pose + hook offset, with the hook
|
||||
// offset first passed through the per-part transform when
|
||||
// available (C.1.5b #56 fix). Without the per-part transform,
|
||||
// every emitter in a multi-emitter PES script collapses to the
|
||||
// entity root — visible symptom: ground-buried portal swirls.
|
||||
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
|
||||
? rot
|
||||
: Quaternion.Identity;
|
||||
Vector3 partLocal = offset;
|
||||
if (_partTransformsByEntity.TryGetValue(entityId, out var partTransforms)
|
||||
&& partIndex >= 0
|
||||
&& partIndex < partTransforms.Count)
|
||||
if (logicalId != 0
|
||||
&& _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing))
|
||||
{
|
||||
partLocal = Vector3.Transform(offset, partTransforms[partIndex]);
|
||||
if (isBlocking && _system.IsEmitterAlive(existing))
|
||||
return;
|
||||
|
||||
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
|
||||
_system.StopEmitter(existing, fadeOut: false);
|
||||
}
|
||||
var anchor = worldPos + Vector3.Transform(partLocal, rotation);
|
||||
var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass)
|
||||
|
||||
Vector3 offsetOrigin = offset?.Origin ?? Vector3.Zero;
|
||||
Quaternion offsetOrientation = offset?.Orientation ?? Quaternion.Identity;
|
||||
if (!TryResolveAnchor(ownerLocalId, partIndex, offsetOrigin,
|
||||
out Vector3 anchor, out Quaternion rotation))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"No live effect pose for owner 0x{ownerLocalId:X8}, part {partIndex}; " +
|
||||
$"emitter 0x{emitterInfoId:X8} was not created.");
|
||||
return;
|
||||
}
|
||||
|
||||
ParticleRenderPass renderPass = _renderPassByEntity.TryGetValue(ownerLocalId, out var pass)
|
||||
? pass
|
||||
: ParticleRenderPass.Scene;
|
||||
|
||||
int handle = _system.SpawnEmitterById(
|
||||
emitterId: emitterInfoId,
|
||||
anchor: anchor,
|
||||
rot: rotation,
|
||||
attachedObjectId: entityId,
|
||||
attachedPartIndex: partIndex,
|
||||
renderPass: renderPass);
|
||||
|
||||
uint keyId = logicalId != 0
|
||||
? logicalId
|
||||
: 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial);
|
||||
if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle))
|
||||
if (!_system.TrySpawnEmitterById(
|
||||
emitterInfoId,
|
||||
anchor,
|
||||
rotation,
|
||||
ownerLocalId,
|
||||
partIndex,
|
||||
renderPass,
|
||||
out int handle))
|
||||
{
|
||||
_system.StopEmitter(oldHandle, fadeOut: false);
|
||||
_trackingByHandle.TryRemove(oldHandle, out _);
|
||||
DiagnosticSink?.Invoke(
|
||||
$"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " +
|
||||
$"0x{ownerLocalId:X8} was not found; no fallback was created.");
|
||||
return;
|
||||
}
|
||||
if (_hiddenPresentationOwners.ContainsKey(ownerLocalId))
|
||||
{
|
||||
_system.SetEmitterPresentationVisible(handle, false);
|
||||
_system.SetEmitterSimulationEnabled(handle, false);
|
||||
}
|
||||
|
||||
_handlesByKey[(entityId, keyId)] = handle;
|
||||
var binding = new EmitterBinding(
|
||||
ownerLocalId,
|
||||
partIndex,
|
||||
offsetOrigin,
|
||||
offsetOrientation,
|
||||
logicalId,
|
||||
renderPass);
|
||||
_bindingsByHandle[handle] = binding;
|
||||
_handlesByEntity
|
||||
.GetOrAdd(entityId, _ => new ConcurrentDictionary<int, byte>())
|
||||
.GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary<int, byte>())
|
||||
.TryAdd(handle, 0);
|
||||
_trackingByHandle[handle] = (entityId, keyId);
|
||||
if (logicalId != 0)
|
||||
_handlesByKey[(ownerLocalId, logicalId)] = handle;
|
||||
}
|
||||
|
||||
private bool TryResolveAnchor(
|
||||
uint ownerLocalId,
|
||||
int partIndex,
|
||||
Vector3 offsetOrigin,
|
||||
out Vector3 anchor,
|
||||
out Quaternion rotation)
|
||||
{
|
||||
if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld))
|
||||
{
|
||||
anchor = default;
|
||||
rotation = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
Matrix4x4 ownerFrame = rootWorld;
|
||||
if (partIndex != -1)
|
||||
{
|
||||
if (!_poses.TryGetPartPose(ownerLocalId, partIndex, out Matrix4x4 partLocal))
|
||||
{
|
||||
anchor = default;
|
||||
rotation = default;
|
||||
return false;
|
||||
}
|
||||
ownerFrame = partLocal * rootWorld;
|
||||
}
|
||||
|
||||
anchor = Vector3.Transform(offsetOrigin, ownerFrame);
|
||||
if (!Matrix4x4.Decompose(ownerFrame, out _, out rotation, out _))
|
||||
{
|
||||
anchor = default;
|
||||
rotation = default;
|
||||
return false;
|
||||
}
|
||||
rotation = Quaternion.Normalize(rotation);
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly record struct EmitterBinding(
|
||||
uint OwnerLocalId,
|
||||
int PartIndex,
|
||||
Vector3 HookOffsetOrigin,
|
||||
Quaternion HookOffsetOrientation,
|
||||
uint LogicalId,
|
||||
ParticleRenderPass RenderPass);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue