From 60f38981dfd95642f999ab9511c70bc82496aae7 Mon Sep 17 00:00:00 2001 From: Yohann Gayao <68230605+DerpyNewb@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:36:57 +0800 Subject: [PATCH 1/4] Add a read model for vfx effect files VfxFile reads a vfx/*.xml effect into its emitters, behaviour chains, render stacks and references. VfxCurve parses the curve properties: channels, keys and the min/max mappings. VfxCurveTests and VfxFileTests cover both; VfxTestFiles builds small vfx files and packs for them. --- .../GameWorld.CoreTest/Vfx/VfxCurveTests.cs | 85 +++++++ .../GameWorld.CoreTest/Vfx/VfxFileTests.cs | 160 +++++++++++++ .../GameWorld.CoreTest/Vfx/VfxTestFiles.cs | 68 ++++++ Shared/GameFiles/Vfx/VfxCurve.cs | 143 ++++++++++++ Shared/GameFiles/Vfx/VfxFile.cs | 210 ++++++++++++++++++ 5 files changed, 666 insertions(+) create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxCurveTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFileTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxTestFiles.cs create mode 100644 Shared/GameFiles/Vfx/VfxCurve.cs create mode 100644 Shared/GameFiles/Vfx/VfxFile.cs diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxCurveTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxCurveTests.cs new file mode 100644 index 000000000..53265d359 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxCurveTests.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using Shared.GameFormats.Vfx; + +namespace GameWorld.Core.Test.Vfx +{ + public class VfxCurveTests + { + // One output between a min and a max channel, each running from (0, 0) to (1, 1); the + // segment eases by the first key's out-interpolation. + static string Segment(string interpolation, string random = "0") => + $"x_min,8405056,0;0.0,0.0,{interpolation},{interpolation},-0.05,0.0,0.05,0.0,1;1.0,1.0,linear,linear,-0.05,0.0,0.05,0.0,1" + + $"|x_max,16728128,0;0.0,0.0,{interpolation},{interpolation},-0.05,0.0,0.05,0.0,1;1.0,1.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,{random};0,1"; + + [TestCase("linear", 0.25f)] + [TestCase("step", 0f)] + [TestCase("step_next", 1f)] + [TestCase("s", 0.15625f)] + [TestCase("s_inv", 0.32635f)] + [TestCase("sine", 0.14645f)] + [TestCase("sine_recip", 0.33333f)] + [TestCase("log1", 0.35737f)] + [TestCase("log3", 0.5843f)] + [TestCase("exp1", 0.1653f)] + [TestCase("exp3", 0.05853f)] + [TestCase("bezier_c", 0.23813f)] + [TestCase("bezier_q", 0.23813f)] + public void Interpolation_EndsAtItsKeys_AndShapesTheSegment(string interpolation, float atQuarter) + { + var curve = VfxCurve.Parse(Segment(interpolation)); + Assert.That(curve, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(curve.Evaluate(0, 0, 0), Is.EqualTo(0).Within(1e-4)); + Assert.That(curve.Evaluate(0, 0.25f, 0), Is.EqualTo(atQuarter).Within(1e-3)); + Assert.That(curve.Evaluate(0, 1, 0), Is.EqualTo(1).Within(1e-4)); + }); + } + + [Test] + public void KeysOutOfTimeOrder_AreSortedFirst() + { + // 105k vanilla channels list their keys like this: the key at 1.0 before the key at 0.0. + var curve = VfxCurve.Parse( + "x_min,0,0;1.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1;0.0,0.0,linear,linear,-0.05,0.0,0.05,0.0,1" + + "|x_max,0,0;1.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1;0.0,0.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1"); + Assert.That(curve.Evaluate(0, 0.5f, 0), Is.EqualTo(5).Within(1e-4)); + } + + [TestCase("1", 12.5f, Description = "random 1: a point between min and max")] + [TestCase("0", 10f, Description = "random 0: the min")] + public void MinMax_FollowTheRandomFlag(string random, float expected) + { + var curve = VfxCurve.Parse( + $"x_min,0,0;0.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,20.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,{random};0,1"); + Assert.That(curve.Evaluate(0, 0.5f, 0.25f), Is.EqualTo(expected).Within(1e-4)); + } + + [Test] + public void Outputs_ReadTheirOwnChannels_AndAMissingOneFallsBack() + { + var curve = VfxCurve.Parse( + "x_min,0,0;0.0,1.0,linear,linear,0,0,0,0,1|x_max,0,0;0.0,1.0,linear,linear,0,0,0,0,1" + + "|y_min,0,0;0.0,2.0,linear,linear,0,0,0,0,1|y_max,0,0;0.0,2.0,linear,linear,0,0,0,0,1:x,0;0,1|y,0;2,3"); + Assert.That((curve.Evaluate(0, 0, 0), curve.Evaluate(1, 0, 0), curve.Evaluate(2, 0, 0, fallback: 7)), Is.EqualTo((1f, 2f, 7f))); + } + + [Test] + public void Constant_HoldsOneValuePerOutput() + { + var curve = VfxCurve.Constant(3, 4); + Assert.That((curve.Evaluate(0, 0.7f, 0.9f), curve.Evaluate(1, 0.2f, 0.1f)), Is.EqualTo((3f, 4f))); + } + + [TestCase("", Description = "empty")] + [TestCase("garbage", Description = "no mapping")] + [TestCase("x_min,0,0;0.0,1.0|x_max,0,0;0.0,1.0:x,0;0,1", Description = "keys with 2 fields, not 9")] + [TestCase("x_min,0,0;0.0,1.0,linear,linear,0,0,0,0:x,0;0,0", Description = "a key with 8 fields, not 9")] + [TestCase("x_min,0,0;0.0,1.0,linear,linear,0,0,0,0,1:x,0;0,3", Description = "a mapping past the last channel")] + [TestCase("x_min,0,0;zero,1.0,linear,linear,0,0,0,0,1:x,0;0,0", Description = "a number that is not one")] + // float.Parse reads these two as infinity and NaN; a modded rate of either hung the spawn loop. + [TestCase("x_min,0,0;0.0,1e39,linear,linear,0,0,0,0,1:x,0;0,0", Description = "a value past float's range")] + [TestCase("x_min,0,0;0.0,NaN,linear,linear,0,0,0,0,1:x,0;0,0", Description = "NaN")] + public void BrokenText_DoesNotParse(string text) => Assert.That(VfxCurve.Parse(text), Is.Null); + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFileTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFileTests.cs new file mode 100644 index 000000000..2b8395e6e --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFileTests.cs @@ -0,0 +1,160 @@ +using System.Text; +using Microsoft.Xna.Framework; +using Shared.GameFormats.Vfx; + +namespace GameWorld.Core.Test.Vfx +{ + public class VfxFileTests + { + [Test] + public void A_tier_takes_its_override_and_the_base_where_it_has_none() + { + var bytes = VfxTestFiles.Xml( + emitters: VfxTestFiles.Emitter("e", """"""), + chains: """"""); + var high = VfxFile.Parse(bytes, tier: 1); + var properties = high.Emitters[0].Properties; + Assert.That((properties["A"].Value, properties["B"].Value, properties.ContainsKey("C")), Is.EqualTo(("2", "3", false))); + Assert.That(high.Chains["e"].Modifiers.Single().Fragment, Is.EqualTo("vs_colour")); + Assert.That(VfxFile.Parse(bytes, tier: 2).Chains.ContainsKey("e"), Is.False); // no block 2: draws nothing at Medium + Assert.That(VfxFile.Parse(bytes).Emitters[0].Properties["A"].Value, Is.EqualTo("1")); + } + + // continuous_rigid particles are meshes: RigidData lists the models, one picked per particle; an entry naming none is dropped. + [Test] + public void Rigid_data_lists_each_model_with_its_pivot_and_animation() + { + var rigid = """"""; + var emitter = VfxFile.Parse(VfxTestFiles.Xml(emitters: VfxTestFiles.Emitter("e", rigid))).Emitters[0]; + Assert.That(emitter.Rigid, Is.EqualTo(new[] + { + new VfxRigidEntry("vfx/models/rock.wsmodel", new Vector3(0.5f, 0.25f, 0.5f), null), + new VfxRigidEntry("vfx/models/rat.wsmodel", new Vector3(0.5f), "animations/rat.anim", 2.5f), + })); + } + + // property_event: OnParticleSpawn and OnParticleDeath name an effect and what of the particle it follows; one naming none + // ("" on 80 vanilla events) starts nothing. + [Test] + public void Particle_events_name_an_effect_and_what_it_follows() + { + var events = """"""; + var emitter = VfxFile.Parse(VfxTestFiles.Xml(emitters: VfxTestFiles.Emitter("e", events))).Emitters[0]; + Assert.That(emitter.Events, Is.EqualTo(new[] { new VfxParticleEvent(false, "trail", true, false, true, false, true, VfxEventTurn.Velocity, -0.3f) })); + } + + [TestCase("")] + [TestCase("0")] + public void An_event_naming_no_effect_starts_nothing(string name) + { + var events = $""""""; + Assert.That(VfxFile.Parse(VfxTestFiles.Xml(emitters: VfxTestFiles.Emitter("e", events))).Emitters[0].Events, Is.Empty); + } + + // ref_spawn_speed="-1.00" is CA's unset; 0 is a speed. + [TestCase("", null)] + [TestCase(" ref_spawn_speed=\"-1.00\"", null)] + [TestCase(" ref_spawn_speed=\"0.00\"", 0f)] + [TestCase(" ref_spawn_speed=\"30.00\"", 30f)] + public void Ref_spawn_speed_of_minus_one_is_unset(string root, float? expected) => + Assert.That(VfxFile.Parse(VfxTestFiles.Xml(root: root)).RefSpawnSpeed, Is.EqualTo(expected)); + + // No vanilla chain lacks a value="0" block; one a modder wrote without it draws nothing at Ultra in game. + [Test] + public void Ultra_reads_no_other_tiers_block() + { + var bytes = VfxTestFiles.Xml( + emitters: VfxTestFiles.Emitter("e", """"""), + chains: """"""); + Assert.That(VfxFile.Parse(bytes).Chains.ContainsKey("e"), Is.False); + } + + const string Sample = """ + + + + + + x_min,0,0;0.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1 + x_min,0,0;0.0,5.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,5.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + // CA writes these files as UTF-16 with a byte order mark. + static VfxFile Parse(string text) => VfxFile.Parse([.. Encoding.Unicode.GetPreamble(), .. Encoding.Unicode.GetBytes(text)]); + + [Test] + public void Emitter_ReadsQualityZeroOnly_InEveryValueShape() + { + var emitter = Parse(Sample).Emitters.Single(); + Assert.Multiple(() => + { + Assert.That((emitter.Id, emitter.Type), Is.EqualTo(("smoke", "continuous_mesh"))); + Assert.That(emitter.Properties.ContainsKey("Enable"), Is.False, "a quality-2-only property is not Ultra's"); + Assert.That(emitter.Properties["CullDistances"].Number("z"), Is.EqualTo(700)); + Assert.That(VfxCurve.Parse(emitter.Properties["EmissionRateCurve"].Text).Evaluate(0, 0, 0), Is.EqualTo(10), "quality 0's curve, not quality 1's"); + Assert.That((emitter.Properties["LifeTime"].Number("min"), emitter.Properties["LifeTime"].Number("max")), Is.EqualTo((1f, 2f))); + Assert.That(emitter.Properties["ParticleSimulationSpace"].Value, Is.EqualTo("LOCAL_SPACE_POSITION_ROTATION")); + }); + } + + [Test] + public void Chain_ReadsTheQualityZeroBlock_WhereverItSits() + { + var chain = Parse(Sample).Chains["smoke"]; + Assert.That(chain.Find("ps_texture").Textures["diffuse"], Is.EqualTo(@"vfx\textures\smoke_dense_loop_diffuse.dds")); + Assert.That(chain.Find("vs_animated_texture").Properties["no_cells_xy"].Float2(Vector2.One), Is.EqualTo(new Vector2(8, 4))); + } + + [Test] + public void RenderStack_KeepsItsSceneViewType() => + Assert.That(Parse(Sample).SceneViewTypes, Is.EqualTo(new Dictionary { ["ripple"] = "DISTORTION" })); + + [Test] + public void Reference_ReadsTransformRangesAndOverrides() + { + var reference = Parse(Sample).References.Single(); + Assert.Multiple(() => + { + Assert.That(reference.Target, Is.EqualTo("wh3_dlc23_lib_warmachines")); + Assert.That((reference.ScaleMin, reference.ScaleMax, reference.StartTimeMin, reference.SimulationSpeed), Is.EqualTo((1f, 2f, -1f, 0.5f))); + Assert.That((reference.Position, reference.RotationDegrees), Is.EqualTo((new Vector3(0.5f, 0, 0), new Vector3(0, 90, 0)))); + Assert.That(reference.InfiniteLife, Is.True); + Assert.That(reference.Overrides["base_red_smoke"].Enable, Is.False); + var fire = reference.Overrides["furnace_fire"]; + Assert.That((fire.TintMin.X, fire.ParticleLifetimeBiasMax, fire.SpawnRateScaleMin), Is.EqualTo((0.5f, 0.2f, 2f))); + }); + } + + [Test] + public void Utf8WithoutBom_ParsesTheSame() => + Assert.That(VfxFile.Parse(Encoding.UTF8.GetBytes(Sample)).Emitters.Single().Id, Is.EqualTo("smoke")); + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxTestFiles.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxTestFiles.cs new file mode 100644 index 000000000..b90a92498 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxTestFiles.cs @@ -0,0 +1,68 @@ +using System.Text; +using Microsoft.Xna.Framework; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.Settings; + +namespace GameWorld.Core.Test.Vfx +{ + // Small vfx files written the way CA's are (UTF-16 with a byte order mark, several root + // elements), in an in-memory pack under vfx/. + static class VfxTestFiles + { + public static byte[] Xml(string emitters = "", string chains = "", string references = "", string stacks = "", string root = "") => + [ + .. Encoding.Unicode.GetPreamble(), + .. Encoding.Unicode.GetBytes( + $"""{emitters}{chains}{stacks}{references}"""), + ]; + + public static string Emitter(string id, string properties = "") => + $"""{properties}"""; + + public static string Reference(string target, string posX = "0.00", string posY = "0.00", string rotY = "0.00", string overrides = "", bool infinite = true) => + $"""{overrides}"""; + + public static string Override(string name, bool enable = true, string tint = "1.00") => + $""""""; + + /// A vector field volume laid out as vanilla's: uncompressed A16B16G16R16F, a volume, no mips; x along a row, y + /// down the rows, z through the slices. + public static byte[] Field(int width, int height, int depth, Func at) + { + var bytes = new byte[128 + width * height * depth * 8]; + "DDS "u8.CopyTo(bytes); + foreach (var (offset, value) in new[] { (4, 124), (8, 0x80100F), (12, height), (16, width), (24, depth), (76, 32), (80, 4), (84, 113), (112, 0x200000) }) + BitConverter.TryWriteBytes(bytes.AsSpan(offset), value); // size, flags, height, width, depth, pixel format, FourCC, D3DFMT_A16B16G16R16F, volume + var n = 128; + for (var z = 0; z < depth; z++) + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++, n += 8) + { + var v = at(x, y, z); + BitConverter.TryWriteBytes(bytes.AsSpan(n), (Half)v.X); + BitConverter.TryWriteBytes(bytes.AsSpan(n + 2), (Half)v.Y); + BitConverter.TryWriteBytes(bytes.AsSpan(n + 4), (Half)v.Z); + } + return bytes; + } + + /// Files under vfx/; a name may carry folders below it ("materials/x.xml.material"). + public static IPackFileService Pack(params (string Name, byte[] Bytes)[] files) => Pack(files.Select(f => + { + var path = "vfx/" + f.Name; + var slash = path.LastIndexOf('/'); + return new NewPackFileEntry(path[..slash], PackFile.CreateFromBytes(path[(slash + 1)..], f.Bytes)); + })); + + public static IPackFileService Pack(IEnumerable files) => Pack(files.Select(f => new NewPackFileEntry("vfx", f))); + + static IPackFileService Pack(IEnumerable entries) + { + var packFileService = new PackFileService(null) { EnforceGameFilesMustBeLoaded = false }; + var container = packFileService.CreateNewPackFileContainer("output", PackFileVersion.PFH5, PackFileCAType.MOD, true); + packFileService.AddFilesToPack(container, entries.ToList()); + return packFileService; + } + } +} diff --git a/Shared/GameFiles/Vfx/VfxCurve.cs b/Shared/GameFiles/Vfx/VfxCurve.cs new file mode 100644 index 000000000..fe8f64524 --- /dev/null +++ b/Shared/GameFiles/Vfx/VfxCurve.cs @@ -0,0 +1,143 @@ +using System.Globalization; + +namespace Shared.GameFormats.Vfx +{ + /// + /// A curve-valued vfx property: "channel|channel:mapping|mapping". A channel is "name,colour,flag;key;key", + /// a key "time,value,inInterp,outInterp,tanInX,tanInY,tanOutX,tanOutY,flag", a mapping + /// "output,random;minChannel,maxChannel". An output lies between its min and max channels: random 1 + /// draws a point between them, 0 uses the min. Measured over vfx_desc.pack (9.0): 520,858 of 524,102 + /// random-0 mappings have identical channels, and 126,495 of 150,773 random-1 mappings differ. + /// + public class VfxCurve + { + public record Key(float Time, float Value, string In, string Out, float TanInX, float TanInY, float TanOutX, float TanOutY); + public record Output(string Name, bool Random, Key[] Min, Key[] Max); + + public IReadOnlyList Outputs { get; } + + VfxCurve(IReadOnlyList outputs) => Outputs = outputs; + + /// Null when the text does not parse; the caller logs it and uses its default. + public static VfxCurve? Parse(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + var split = text.LastIndexOf(':'); + if (split < 0) + return null; + + try + { + var channels = text[..split].Split('|').Select(ParseChannel).ToArray(); + var outputs = text[(split + 1)..].Split('|').Select(mapping => + { + var parts = mapping.Split(';'); + var head = parts[0].Split(','); + var range = parts[1].Split(','); + var min = channels[int.Parse(range[0], CultureInfo.InvariantCulture)]; + var max = channels[int.Parse(range[1], CultureInfo.InvariantCulture)]; + return new Output(head[0].Trim(), head[1].Trim() == "1", min, max); + }).ToArray(); + return new VfxCurve(outputs); + } + catch (Exception e) when (e is FormatException or IndexOutOfRangeException or OverflowException) + { + return null; + } + } + + public static VfxCurve Constant(params float[] values) => new(values.Select(value => + { + Key[] keys = [new Key(0, value, "linear", "linear", 0, 0, 0, 0)]; + return new Output("x", false, keys, keys); + }).ToArray()); + + // 105k vanilla channels list their keys out of time order. + static Key[] ParseChannel(string channel) => channel.Split(';').Skip(1).Select(key => + { + var f = key.Split(','); + if (f.Length != 9) + throw new FormatException($"a key has {f.Length} fields, not 9"); + return new Key(Number(f[0]), Number(f[1]), f[2], f[3], Number(f[4]), Number(f[5]), Number(f[6]), Number(f[7])); + }).OrderBy(x => x.Time).ToArray(); + + // float.Parse reads "1e39" as infinity and "NaN" as NaN; neither is a value a curve can hold. + static float Number(string text) => float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture) is var value && float.IsFinite(value) + ? value + : throw new FormatException($"{text} is not a finite number"); + + public float Evaluate(int output, float t, float random, float fallback = 0) + { + if (output >= Outputs.Count) + return fallback; + var o = Outputs[output]; + var min = Sample(o.Min, t); + return o.Random ? min + (Sample(o.Max, t) - min) * random : min; + } + + public static float Sample(Key[] keys, float t) + { + if (keys.Length == 0) + return 0; + if (t <= keys[0].Time) + return keys[0].Value; + + for (var i = 0; i < keys.Length - 1; i++) + { + var (a, b) = (keys[i], keys[i + 1]); + if (t >= b.Time) + continue; + if (a.Out is "bezier_c" or "bezier_q") + return Bezier(a, b, t); + return a.Value + (b.Value - a.Value) * Ease(a.Out, (t - a.Time) / (b.Time - a.Time)); + } + return keys[^1].Value; + } + + /// + /// A segment eases by its left key's out-interpolation. These shapes are a reading of the names, + /// not CA's code. An unknown name eases linearly. + /// + public static float Ease(string interpolation, float u) => interpolation switch + { + "step" => 0, + "step_next" => 1, + "s" => u * u * (3 - 2 * u), + "s_inv" => 0.5f - MathF.Sin(MathF.Asin(1 - 2 * u) / 3), + "sine" => (1 - MathF.Cos(MathF.PI * u)) / 2, + "sine_recip" => MathF.Acos(1 - 2 * u) / MathF.PI, + "log1" => Log(u, 1), + "log3" => Log(u, 3), + "exp1" => Exp(u, 1), + "exp3" => Exp(u, 3), + _ => u, + }; + + static float Log(float u, float k) => MathF.Log(1 + (MathF.Exp(k) - 1) * u) / k; + static float Exp(float u, float k) => (MathF.Exp(k * u) - 1) / (MathF.Exp(k) - 1); + + // A cubic through the two keys whose handles are the tangent offsets (time, value) from each key. + // bezier_q is drawn the same way. + static float Bezier(Key a, Key b, float t) + { + float lo = 0, hi = 1; + for (var i = 0; i < 30; i++) + { + var mid = (lo + hi) / 2; + if (Cubic(a.Time, a.Time + a.TanOutX, b.Time + b.TanInX, b.Time, mid) < t) + lo = mid; + else + hi = mid; + } + return Cubic(a.Value, a.Value + a.TanOutY, b.Value + b.TanInY, b.Value, (lo + hi) / 2); + } + + static float Cubic(float p0, float p1, float p2, float p3, float s) + { + var r = 1 - s; + return r * r * r * p0 + 3 * r * r * s * p1 + 3 * r * s * s * p2 + s * s * s * p3; + } + } +} diff --git a/Shared/GameFiles/Vfx/VfxFile.cs b/Shared/GameFiles/Vfx/VfxFile.cs new file mode 100644 index 000000000..412bcb93c --- /dev/null +++ b/Shared/GameFiles/Vfx/VfxFile.cs @@ -0,0 +1,210 @@ +using System.Globalization; +using System.IO; +using System.Text; +using System.Xml.Linq; +using Microsoft.Xna.Framework; + +namespace Shared.GameFormats.Vfx +{ + /// One property as written: its attributes (value, x/y/z/w, min/max, type, path) and, for a curve, its text. + public record VfxProperty(string Name, IReadOnlyDictionary Attributes, string? Text) + { + public string? Value => Attributes.GetValueOrDefault("value"); + public bool IsTrue => string.Equals(Value, "true", StringComparison.OrdinalIgnoreCase); + + public float Number(string attribute, float fallback = 0) => + Attributes.TryGetValue(attribute, out var text) && float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback; + + /// A "float2(8.0,8.0)" value. + public Vector2 Float2(Vector2 fallback) + { + var parts = Value?.Replace("float2(", "").TrimEnd(')').Split(','); + return parts is { Length: 2 } + && float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x) + && float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y) + ? new Vector2(x, y) : fallback; + } + } + + /// One model a continuous_rigid emitter's particles may be: RigidData's rigid_entry. + public record VfxRigidEntry(string ModelFile, Vector3 Pivot, string? AnimFile, float AnimSpeed = 1); + + /// How a particle event's effect turns with its particle: track_orientation_mode disabled or unset, orientation (the + /// particle's own turn), or orientation_by_velocity. + public enum VfxEventTurn { None, Particle, Velocity } + + /// property_event OnParticleSpawn or OnParticleDeath (689 and 61 vanilla events that name an effect): the effect a + /// particle starts at its birth or its death, what of the particle it follows, and the event's time offset in seconds. + public record VfxParticleEvent(bool OnDeath, string Effect, bool TrackPosition, bool TrackScale, bool TrackColour, bool TrackAlpha, + bool TrackLifetime, VfxEventTurn Turn, float Offset); + + /// Rigid: RigidData's models, a continuous_rigid emitter's; null or empty for any other. Events: its particle events. + public record VfxEmitter(string Id, string Type, IReadOnlyDictionary Properties, IReadOnlyList? Rigid = null, + IReadOnlyList? Events = null); + + public record VfxModifier(string Fragment, IReadOnlyDictionary Properties, IReadOnlyDictionary Textures); + + public record VfxBehaviourChain(string Id, IReadOnlyList Modifiers) + { + public VfxModifier? Find(string fragment) => Modifiers.FirstOrDefault(x => x.Fragment == fragment); + } + + /// What a reference says about one emitter of the file it references, by name. + public record VfxEmitterOverride(string Name, bool Enable, Vector4 TintMin, Vector4 TintMax, + float ParticleLifetimeBiasMin, float ParticleLifetimeBiasMax, float EmitterLifetimeBiasMin, float EmitterLifetimeBiasMax, + float SpawnRateScaleMin, float SpawnRateScaleMax) + { + public static readonly VfxEmitterOverride None = new("", true, Vector4.One, Vector4.One, 0, 0, 0, 0, 1, 1); + } + + public record VfxReference(string Target, float ScaleMin, float ScaleMax, Vector3 Position, Vector3 RotationDegrees, + float StartTimeMin, float StartTimeMax, bool InfiniteLife, float SimulationSpeed, IReadOnlyDictionary Overrides); + + /// + /// One vfx/*.xml, read at one quality tier (0 is Ultra, the default). The file has several root elements + /// (shader_library, vfx, vfx_references, editor_data), so it is parsed inside a wrapper element. Emitters, behaviour + /// chains and render stacks share one id per emitter. + /// + public class VfxFile + { + public List Emitters { get; } = []; + public Dictionary Chains { get; } = []; + /// Render stack id to its quality-0 scene_view_type, for the stacks that set one. + public Dictionary SceneViewTypes { get; } = []; + /// Render stack ids with enable_trail_mode on: their emitter's particles are drawn as one strip. + public HashSet TrailModes { get; } = []; + public List References { get; } = []; + /// The vfx element's ref_spawn_speed: 30 on 10 flamethrower and acid effects and 15 on one; null when -1, as on + /// the other 7,459, or absent. + public float? RefSpawnSpeed { get; private set; } + + public static VfxFile Parse(byte[] bytes, int tier = 0) + { + using var reader = new StreamReader(new MemoryStream(bytes), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + var text = reader.ReadToEnd(); + if (text.StartsWith("") + 2)..]; + var root = XElement.Parse("" + text + ""); + + var file = new VfxFile(); + var vfx = root.Element("vfx"); + if (vfx != null && Num(vfx, "ref_spawn_speed", -1) is var refSpawnSpeed && refSpawnSpeed != -1) + file.RefSpawnSpeed = refSpawnSpeed; + + foreach (var emitter in vfx?.Element("emitters")?.Elements("emitter") ?? []) + { + var properties = Properties(emitter.Elements().Where(x => x.Name.LocalName is "property" or "ranged_property"), tier); + file.Emitters.Add(new VfxEmitter((string?)emitter.Attribute("id") ?? "", (string?)emitter.Attribute("type") ?? "", properties, Rigid(emitter, tier), Events(emitter, tier))); + } + + foreach (var chain in vfx?.Element("behaviour_chains")?.Elements("behaviour_chain") ?? []) + { + var blocks = chain.Elements("quality").ToList(); + // A block has no fallback: with no block at the tier the + // chain draws nothing there, Ultra included. + var block = blocks.FirstOrDefault(x => (string?)x.Attribute("value") == tier.ToString(CultureInfo.InvariantCulture)); + if (block == null) + continue; + var modifiers = block.Elements("modifier").Select(m => new VfxModifier( + (string?)m.Attribute("fragment") ?? "", + Properties(m.Elements("property")), + m.Elements("texture_property").GroupBy(t => (string?)t.Attribute("name") ?? "").ToDictionary(g => g.Key, g => (string?)g.First().Attribute("path") ?? ""))).ToList(); + var id = (string?)chain.Attribute("id") ?? ""; + file.Chains[id] = new VfxBehaviourChain(id, modifiers); + } + + foreach (var stack in vfx?.Element("effect_render_stacks")?.Elements("effect_render_stack") ?? []) + { + var id = (string?)stack.Attribute("id") ?? ""; + var properties = Properties(stack.Elements("property"), tier); + if (properties.TryGetValue("scene_view_type", out var view) && view.Value != null) + file.SceneViewTypes[id] = view.Value; + if (properties.TryGetValue("enable_trail_mode", out var trail) && trail.IsTrue) + file.TrailModes.Add(id); + } + + foreach (var reference in root.Element("vfx_references")?.Elements("vfx") ?? []) + { + var overrides = new Dictionary(); + foreach (var o in reference.Elements("emitter")) + { + var name = (string?)o.Attribute("name") ?? ""; + overrides[name] = new VfxEmitterOverride(name, (string?)o.Attribute("enable") != "false", + new Vector4(Num(o, "tint_min_r", 1), Num(o, "tint_min_g", 1), Num(o, "tint_min_b", 1), Num(o, "tint_min_a", 1)), + new Vector4(Num(o, "tint_max_r", 1), Num(o, "tint_max_g", 1), Num(o, "tint_max_b", 1), Num(o, "tint_max_a", 1)), + Num(o, "particle_lifetime_bias_min", 0), Num(o, "particle_lifetime_bias_max", 0), + Num(o, "emitter_lifetime_bias_min", 0), Num(o, "emitter_lifetime_bias_max", 0), + Num(o, "spawn_rate_scale_factor_min", 1), Num(o, "spawn_rate_scale_factor_max", 1)); + } + + file.References.Add(new VfxReference((string?)reference.Attribute("vfx_ref") ?? "", + Num(reference, "scale_min", 1), Num(reference, "scale_max", 1), + new Vector3(Num(reference, "pos_x", 0), Num(reference, "pos_y", 0), Num(reference, "pos_z", 0)), + new Vector3(Num(reference, "rot_x", 0), Num(reference, "rot_y", 0), Num(reference, "rot_z", 0)), + Num(reference, "start_time_min", 0), Num(reference, "start_time_max", 0), + (string?)reference.Attribute("infinite_life") == "true", Num(reference, "simulation_speed_multiplier", 1), overrides)); + } + + return file; + } + + // Tier 0 (or no quality attribute) is the base; a property written at the asked tier replaces it. The first one + // written at each wins. + static Dictionary Properties(IEnumerable elements, int tier = 0) + { + var output = new Dictionary(); + var overrides = new Dictionary(); + var asked = tier.ToString(CultureInfo.InvariantCulture); + foreach (var element in elements) + { + var name = (string?)element.Attribute("name"); + var quality = (string?)element.Attribute("quality") ?? "0"; + var target = quality == "0" ? output : quality == asked ? overrides : null; + if (name == null || target == null) + continue; + var attributes = element.Attributes().Where(a => a.Name.LocalName is not ("name" or "quality")).ToDictionary(a => a.Name.LocalName, a => a.Value); + target.TryAdd(name, new VfxProperty(name, attributes, element.Value.Length == 0 ? null : element.Value)); + } + foreach (var (name, property) in overrides) + output[name] = property; + return output; + } + + // property_event at the asked tier, else its base; one naming no effect ("" or "0") starts nothing. + static List Events(XElement emitter, int tier) => Properties(emitter.Elements("property_event"), tier).Values + .Where(x => x.Name is "OnParticleSpawn" or "OnParticleDeath" && x.Attributes.GetValueOrDefault("vfx_name") is { Length: > 0 } name && name != "0") + .Select(x => + { + bool Flag(string attribute) => x.Attributes.GetValueOrDefault(attribute) == "true"; + var death = x.Name == "OnParticleDeath"; + var turn = x.Attributes.GetValueOrDefault("track_orientation_mode") switch + { + "orientation_by_velocity" => VfxEventTurn.Velocity, + "orientation" => VfxEventTurn.Particle, + _ => VfxEventTurn.None, + }; + return new VfxParticleEvent(death, x.Attributes["vfx_name"], Flag("track_position"), Flag("track_scale"), Flag("track_colour"), Flag("track_alpha"), + Flag("track_lifetime"), turn, x.Number(death ? "death_event_time_offset" : "spawn_event_time_offset")); + }) + .ToList(); + + // RigidData written at the asked tier, else its base. + static List Rigid(XElement emitter, int tier) + { + var blocks = emitter.Elements("property_rigid_data").ToList(); + var asked = tier.ToString(CultureInfo.InvariantCulture); + var block = blocks.FirstOrDefault(x => (string?)x.Attribute("quality") == asked) ?? blocks.FirstOrDefault(x => ((string?)x.Attribute("quality") ?? "0") == "0"); + return block?.Elements("rigid_entry") + .Select(entry => new VfxRigidEntry( + (string?)entry.Element("ModelFile")?.Attribute("value") ?? "", + entry.Element("PivotPoint") is { } pivot ? new Vector3(Num(pivot, "x", 0.5f), Num(pivot, "y", 0.5f), Num(pivot, "z", 0.5f)) : new Vector3(0.5f), + (string?)entry.Element("AnimFile")?.Attribute("value") is { Length: > 0 } anim ? anim : null, + entry.Element("AnimSpeed") is { } speed ? Num(speed, "value", 1) : 1)) + .Where(x => x.ModelFile.Length > 0) + .ToList() ?? []; + } + + static float Num(XElement element, string attribute, float fallback) => + float.TryParse((string?)element.Attribute(attribute), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback; + } +} From 360259c5d8b93add04d8b00d0a32bb227c4f6b2e Mon Sep 17 00:00:00 2001 From: Yohann Gayao <68230605+DerpyNewb@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:36:57 +0800 Subject: [PATCH 2/4] Add the VFX particle preview Simulates an effect from its vfx file and draws its particles: continuous_mesh and continuous_rigid emitters, flipbooks, trails, vector fields, soft edges, heat haze, and smoke lit by the scene's sun and by light particles. The particle shader is Shaders/Particle.fx. Changes outside GameWorld.Core/Vfx: - RenderEngineComponent draws the scene's depth first when there are particles (soft particles fade against it), draws the new Transparent bucket after the solid scene in both passes, passes the light particles to the scene as PointLights, adds the glow as light whatever its alpha, and saves screenshots as straight-alpha PNGs. - SaveRenderImageSettings takes an optional DrawOverlays (default true). False also leaves out the labels, axis widget, FPS text, wireframe and selected faces. DrawLines is unchanged, and so is PhotoStudio. - RenderBuckedId gains Transparent and RenderingTechnique gains Depth. CommonShaderParameters gains ModelColour, PointLights and SceneDepth. - The MetalRough and SpecGloss shaders and materials take the point lights and the depth technique. ResourceLibrary loads the Particle effect. Tests cover the simulator, the renderer, the effect resolver and the screenshot PNG. The ones that need the game's files are [Explicit] and read WH3_DATA. --- GameWorld/ContentProject/Content/Content.mgcb | 6 + .../Content/Shaders/Particle.fx | 385 ++++++ .../Pbr/MetalRoughness/MetalRoughness_main.fx | 30 +- .../Shaders/Pbr/Shared/const_layout.hlsli | 21 + .../Shaders/Pbr/Shared/light_falloff.hlsli | 13 + .../Shaders/Pbr/SpecGloss/SpecGloss_main.fx | 29 +- .../Components/Rendering/IRenderItem.cs | 5 +- .../Rendering/RenderEngineComponent.cs | 117 +- .../Rendering/CommonShaderParameters.cs | 35 +- .../Capabilities/CommonShaderParameters.cs | 8 + .../Shaders/MetalRough/DefaultMaterial.cs | 1 + .../Shaders/MetalRough/EmissiveMaterial.cs | 1 + .../Shaders/SpecGloss/AdvancedRmvMaterial.cs | 1 + .../Shaders/SpecGloss/DefaultMaterial.cs | 1 + .../GameWorld.Core/Services/ResourceLibary.cs | 6 +- .../GameWorld.Core/Vfx/EmitterSettings.cs | 479 +++++++ .../GameWorld.Core/Vfx/ParticleRenderItem.cs | 571 +++++++++ .../GameWorld.Core/Vfx/ParticleSimulator.cs | 575 +++++++++ .../GameWorld.Core/Vfx/VfxConventions.cs | 280 ++++ .../GameWorld.Core/Vfx/VfxEffectNode.cs | 23 + .../GameWorld.Core/Vfx/VfxMeshCache.cs | 171 +++ .../GameWorld.Core/Vfx/VfxResolver.cs | 215 ++++ .../GameWorld.Core/Vfx/VfxTextureCache.cs | 47 + .../GameWorld.Core/Vfx/VfxVectorField.cs | 57 + .../Rendering/RenderEngineVfxTests.cs | 122 ++ .../Rendering/ScreenshotAlphaTests.cs | 48 + .../Vfx/EmitterSettingsTests.cs | 450 +++++++ .../Vfx/ParticleRenderItemTests.cs | 1134 +++++++++++++++++ .../Vfx/ParticleSimulatorTests.cs | 631 +++++++++ .../GameWorld.CoreTest/Vfx/VanillaPacks.cs | 25 + .../Vfx/VanillaVfxSurvey.cs | 83 ++ .../GameWorld.CoreTest/Vfx/VfxFrameCost.cs | 103 ++ .../Vfx/VfxPreviewRender.cs | 125 ++ .../Vfx/VfxResolverTests.cs | 143 +++ 34 files changed, 5920 insertions(+), 21 deletions(-) create mode 100644 GameWorld/ContentProject/Content/Shaders/Particle.fx create mode 100644 GameWorld/ContentProject/Content/Shaders/Pbr/Shared/light_falloff.hlsli create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/EmitterSettings.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/ParticleRenderItem.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/ParticleSimulator.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxConventions.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxEffectNode.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxMeshCache.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxResolver.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxTextureCache.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxVectorField.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Rendering/RenderEngineVfxTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Rendering/ScreenshotAlphaTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/EmitterSettingsTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/ParticleRenderItemTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/ParticleSimulatorTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VanillaPacks.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VanillaVfxSurvey.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFrameCost.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxPreviewRender.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxResolverTests.cs diff --git a/GameWorld/ContentProject/Content/Content.mgcb b/GameWorld/ContentProject/Content/Content.mgcb index 2e5ae4c18..389f534a1 100644 --- a/GameWorld/ContentProject/Content/Content.mgcb +++ b/GameWorld/ContentProject/Content/Content.mgcb @@ -74,6 +74,12 @@ /processorParam:DebugMode=Auto /build:Shaders/Pbr/SpecGloss/SpecGloss_main.fx +#begin Shaders/Particle.fx +/importer:EffectImporter +/processor:EffectProcessor +/processorParam:DebugMode=Auto +/build:Shaders/Particle.fx + #begin Shaders/TexturePreview.fx /importer:EffectImporter /processor:EffectProcessor diff --git a/GameWorld/ContentProject/Content/Shaders/Particle.fx b/GameWorld/ContentProject/Content/Shaders/Particle.fx new file mode 100644 index 000000000..4da2ee2c4 --- /dev/null +++ b/GameWorld/ContentProject/Content/Shaders/Particle.fx @@ -0,0 +1,385 @@ +#if OPENGL + #define SV_POSITION POSITION + #define VS_SHADERMODEL vs_3_0 + #define PS_SHADERMODEL ps_3_0 +#else + #define VS_SHADERMODEL vs_4_0 + #define PS_SHADERMODEL ps_4_0 +#endif + +// The VFX preview's particles, drawn by GameWorld.Core/Vfx/ParticleRenderItem.cs. Every reading of a CA fragment here +// is a guess, like VfxConventions' own; ParticleRenderItemTests draw each one on the GPU. ps_lighting +// is not a guess: it is CA's own ps_main (fx/particle_vfx_main in shaders.pack), disassembled. + +#include "Pbr/Helpers/tone_mapping.hlsli" +#include "Pbr/Shared/light_falloff.hlsli" + +float4x4 WorldViewProjection; +// The view depth comes off it, which a clip w gives only in perspective. +float4x4 WorldView; +float4x4 World; +// A vertex colour stops at 1: an HDR emitter's colours arrive divided by Scale and are multiplied back here. +float Scale = 1; +float Premultiplied = 1; +// ps_texture overlay_colour: the vertex colour comes straight and is overlaid on the texture; the chain's additive brightness +// and alpha weight (VfxConventions.Blend) go on after. +float Overlay = 0; +float OverlayBrightness = 1; +float OverlayAlphaWeight = 1; +// The glow pass, as CA's bloom bright pass: what a particle draws, weighed by how far the luminance of what it adds to the +// scene lies over the threshold, times the particles' strength (VfxConventions.BloomThreshold). +float Glow = 0; +float BloomThreshold = 4; +float BloomStrength = 0.5; +// ps_alpha_crush sharp_transition. +float CrushSharp = 0; +// ps_gradient: whether the chain has one, its alpha_mapping, and its strip at 16 points from 0 to 1. +float UseGradient = 0; +float GradientMapsAlpha = 0; +float4 GradientRamp[16]; + +Texture2D Texture; +sampler2D TextureSampler = sampler_state +{ + Texture = ; +}; + +// DISTORTION: the scene drawn so far, copied, its texel size, and the share of the view a full shift moves it, in texture +// units across and down (VfxConventions.DistortionShare). +Texture2D Scene; +sampler2D SceneSampler = sampler_state +{ + Texture = ; + AddressU = Clamp; + AddressV = Clamp; + MinFilter = Linear; + MagFilter = Linear; + MipFilter = Point; +}; +float2 InverseSceneSize; +float2 Reach; + +// ps_soft_edge against the solid scene: its view depth per pixel, drawn by the engine before the particles, that texture's +// texel size, and the emitter's soft_factor and scale_strength. UseSceneDepth is 0 when there is none to fade against. +// Occlude: the glow pass, whose target holds only the emissive models' depth, so the scene's depth hides what lies behind it. +Texture2D SceneDepth; +sampler2D SceneDepthSampler = sampler_state +{ + Texture = ; + AddressU = Clamp; + AddressV = Clamp; + MinFilter = Point; + MagFilter = Point; + MipFilter = Point; +}; +float2 InverseDepthSize; +float UseSceneDepth = 0; +float Occlude = 0; +float SoftFactor = 1; +float SoftStrength = 1; + +// ps_lighting on a chain blended by alpha (EmitterSettings.Lit), in the world: the way to the sun, the light a face square to +// it takes and the ambient cube's scale, both as Shaders/Pbr light a model; the camera's axis toward the camera; and the +// chain's small_particles, large_particles, directionality and emissive (xyzw) and average_size. +float Lit = 0; +float3 SunDirection; +float3 SunColour; +float3 AmbientColour; +float3 CameraBack; +float4 Scattering; +float AverageSize; +TextureCube AmbientCube; +samplerCUBE AmbientSampler = sampler_state +{ + Texture = ; + MinFilter = Linear; + MagFilter = Linear; + MipFilter = Linear; +}; +// ps_normal: the chain's normal map, which shapes that light, and whether there is one. +float UseNormalMap = 0; +Texture2D NormalMap; +sampler2D NormalSampler = sampler_state +{ + Texture = ; +}; + +struct VertexShaderInput +{ + float4 Position : POSITION0; + float4 Colour : COLOR0; + float2 Uv : TEXCOORD0; + // The next flipbook cell's texture coordinate (xy), how far to blend into it (z), the alpha crush level (w). + float4 Extra : TEXCOORD1; + // The particle's width and height, metres. + float2 Size : TEXCOORD2; +}; + +struct VertexShaderOutput +{ + float4 Position : SV_POSITION; + float4 Colour : COLOR0; + float2 Uv : TEXCOORD0; + float4 Extra : TEXCOORD1; + // The view depth, for ps_soft_edge. + float Depth : TEXCOORD2; + float3 WorldPosition : TEXCOORD3; + float2 Size : TEXCOORD4; +}; + +// sRGB to linear, as CA's ps_main decodes a texture's alpha, and back. +float Linear(float x) +{ + return x <= 0.04045 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4); +} + +float3 Linear(float3 x) +{ + return float3(Linear(x.r), Linear(x.g), Linear(x.b)); +} + +float Gamma(float x) +{ + return x <= 0.0031308 ? x * 12.92 : 1.055 * pow(x, 1 / 2.4) - 0.055; +} + +float3 Gamma(float3 x) +{ + return float3(Gamma(x.r), Gamma(x.g), Gamma(x.b)); +} + +// CA's process_texture_colour calls blend_overlay(texture, colour), which no shipped CA source defines: this is the standard +// overlay, with the texture as the base. +float3 BlendOverlay(float3 base, float3 blend) +{ + return lerp(2 * base * blend, 1 - 2 * (1 - base) * (1 - blend), step(0.5, base)); +} + +// The quad's own plane, on the side facing the camera, as CA's billboard faces it. +float3 Facing(float3 worldPosition) +{ + float3 n = normalize(cross(ddx(worldPosition), ddy(worldPosition))); + return dot(n, CameraBack) < 0 ? -n : n; +} + +// ps_normal, as CA's ps_main reads a DXT5nm texel: x in alpha and y in green, each half a step up, z what is left. +float3 TangentNormal(float2 uv) +{ + float2 xy = (tex2D(NormalSampler, uv).ag + 0.001961) * 2 - 1; + return float3(xy, sqrt(max(1 - dot(xy, xy), 0))); +} + +// The normal the light meets: the quad's plane, or with ps_normal its map's, x along the texture's u and y along its v (down). +// The u and v ways come off the screen derivatives (a cotangent frame), so a mirrored or turned texture turns its normals with it. +float3 Normal(VertexShaderOutput input) +{ + float3 n = Facing(input.WorldPosition); + if (UseNormalMap <= 0) + return n; + float3 t = lerp(TangentNormal(input.Uv), TangentNormal(input.Extra.xy), input.Extra.z); + float3 dp1 = ddx(input.WorldPosition); + float3 dp2 = ddy(input.WorldPosition); + float2 duv1 = ddx(input.Uv); + float2 duv2 = ddy(input.Uv); + float3 across = cross(dp2, n); + float3 down = cross(n, dp1); + float s = sign(dot(n, cross(dp1, dp2))); + float3 u = normalize(across * duv1.x + down * duv2.x) * s; + float3 v = normalize(across * duv1.y + down * duv2.y) * s; + return normalize(t.x * u + t.y * v + t.z * n); +} + +// ps_lighting, as CA's ps_main computes it for a particle of this width with this normal: sunlight scattered through it, +// Rayleigh's phase for small_particles and Henyey-Greenstein's, (1 - g)^2 over it, for large_particles at g = directionality, +// each weighted by its own amount, over the light let straight through, exp(-(small + large) x average_size x width x +// (1 - N.L) / 2); then drawn toward emissive by as much as emissive is, up to all of it; plus the sky's light from the +// normal's way. No particles to scatter is 0 / 0 in CA's, which the GPU saturates to no sun at all. +float3 ParticleLight(float3 n, float width) +{ + float small = Scattering.x; + float large = Scattering.y; + float g = Scattering.z; + float sigma = small + large; + float c = dot(-SunDirection, CameraBack); + float through = exp(-sigma * AverageSize * width * (1 - dot(n, SunDirection)) * 0.5); + float rayleigh = 0.059683 * (1 + c * c); + float henyey = (1 - g) * (1 - g) * 0.079577 / pow(max(1 + g * g - 2 * g * c, 0.000001), 1.5); + float sun = sigma > 0 ? saturate((small * rayleigh + large * henyey) / sigma * (1 - through) + through) : 0; + return lerp(sun * SunColour, Scattering.www, saturate(Scattering.w)) + texCUBE(AmbientSampler, n).rgb * AmbientColour; +} + +// Straight alpha adds its colour times its alpha, premultiplied its colour as drawn (ParticleRenderItem.BlendFor). +float4 Bright(float4 drawn) +{ + float3 added = Premultiplied > 0 ? drawn.rgb : drawn.rgb * drawn.a; + if (Glow > 0) + drawn.rgb *= saturate(dot(added, float3(0.2126, 0.7152, 0.0722)) - BloomThreshold) * BloomStrength; + return drawn; +} + +float4 Ramp(float t) +{ + float x = saturate(t) * 15; + float i = min(floor(x), 14); + return lerp(GradientRamp[(int)i], GradientRamp[(int)i + 1], x - i); +} + +VertexShaderOutput MainVS(in VertexShaderInput input) +{ + VertexShaderOutput output; + output.Position = mul(input.Position, WorldViewProjection); + output.Colour = input.Colour * float4(Scale, Scale, Scale, 1); + output.Uv = input.Uv; + output.Extra = input.Extra; + output.Depth = -mul(input.Position, WorldView).z; + output.WorldPosition = mul(input.Position, World).xyz; + output.Size = input.Size; + return output; +} + +float4 MainPS(VertexShaderOutput input) : COLOR +{ + // vs_animated_texture cell_blend: this cell mixed into the next (VfxConventions.FlipbookFrame). + float4 texel = lerp(tex2D(TextureSampler, input.Uv), tex2D(TextureSampler, input.Extra.xy), input.Extra.z); + + // The rest works on the straight colour: a premultiplied texel is divided out first and multiplied back after. + float a = texel.a; + float3 rgb = Premultiplied > 0 && a > 0 ? texel.rgb / a : texel.rgb; + // ps_texture: CA reads the alpha as sRGB, as the hardware reads the colour off its BC7_UNORM_SRGB files, so a texel covers + // its alpha linearised. The colour stays as stored: this shader works in the screen's gamma. + a = Linear(a); + + // ps_gradient, read as a gradient map: the texel's red picks a colour off the strip. Its alpha is multiplied by the + // strip's there, or with alpha_mapping is put through the strip's alpha itself. + if (UseGradient > 0) + { + float4 picked = Ramp(rgb.r); + rgb = picked.rgb; + a = GradientMapsAlpha > 0 ? Ramp(a).a : a * picked.a; + } + + // ps_alpha_crush: alpha under the particle's crush level goes. A sharp crush cuts there and keeps what is over; a soft + // one stretches what is left back over 0..1, so the texture thins from its faint edges in. + float crush = input.Extra.w; + a = CrushSharp > 0 ? (a > crush ? a : 0) : saturate((a - crush) / max(1 - crush, 0.0001)); + + // ps_soft_edge, as CA's ps_main reads it: fades in, linearly, over soft_factor metres of view depth in front of the solid + // scene, shrunk for a particle under a metre across by scale_strength, as VfxConventions.SoftDistance has it in front of the + // ground. + if (UseSceneDepth > 0 || Occlude > 0) + { + float scene = tex2D(SceneDepthSampler, input.Position.xy * InverseDepthSize).r; + if (UseSceneDepth > 0) + a *= saturate((scene - input.Depth) / (SoftFactor * lerp(1, saturate(min(input.Size.x, input.Size.y)), SoftStrength) + 0.001)); + if (Occlude > 0 && scene < input.Depth) + a = 0; + } + + // ps_texture overlay_colour, as CA's process_texture_colour has it: the colour is overlaid on the texture, in linear as CA's + // colours are, rather than multiplied in; the alpha still multiplies. Lit, the overlaid colour is the albedo (a lit chain has + // no additive blend, so no brightness or alpha weight). + if (Overlay > 0) + { + float3 overlaid = BlendOverlay(UseGradient > 0 ? rgb : saturate(Linear(rgb)), input.Colour.rgb); + rgb = Lit > 0 + ? Gamma(saturate(Uncharted2ToneMapping(overlaid * ParticleLight(Normal(input), input.Size.x)))) + : Gamma(saturate(overlaid)) * OverlayBrightness; + a *= input.Colour.a; + return Bright(float4(Premultiplied > 0 ? rgb * a : rgb, Premultiplied > 0 && Lit == 0 ? a * OverlayAlphaWeight : a)); + } + + // ps_lighting: the colour is linear, the texture's decoded (a gradient's strip is linear already), times the vertex colour, + // lit, and tone-mapped and encoded as Shaders/Pbr bring a model's lit colour to the screen. The vertex colour comes straight. + if (Lit > 0) + { + float3 albedo = (UseGradient > 0 ? rgb : saturate(Linear(rgb))) * input.Colour.rgb; + rgb = Gamma(saturate(Uncharted2ToneMapping(albedo * ParticleLight(Normal(input), input.Size.x)))); + a *= input.Colour.a; + return Bright(float4(Premultiplied > 0 ? rgb * a : rgb, a)); + } + + return Bright(float4(Premultiplied > 0 ? rgb * a : rgb, a) * input.Colour); +} + +struct DistortionOutput +{ + float4 Position : SV_POSITION; + float4 Colour : COLOR0; + float2 Uv : TEXCOORD0; +}; + +DistortionOutput DistortionVS(in VertexShaderInput input) +{ + DistortionOutput output; + output.Position = mul(input.Position, WorldViewProjection); + output.Colour = input.Colour; + output.Uv = input.Uv; + return output; +} + +// CA's ps_distortion_alphablend and fx_hdr_to_screen: the map is DXT5nm, x in alpha and y in green (red is unused, and 255 in +// shockwave_normal), made unit with z rebuilt. The particle's alpha times the tilt is its cover, and the pixel under it is read +// from Reach x cover^2 x tilt away along the tilt. Written back whole, alpha too, so the scene moves and leaves no copy behind; +// y is added down the screen as CA adds it. One particle's shift only; CA sums overlapping ones in its buffer first. +float4 DistortionPS(DistortionOutput input) : COLOR +{ + float2 xy = tex2D(TextureSampler, input.Uv).ag * 2 - 1; + float3 n = normalize(float3(xy, sqrt(max(1 - dot(xy, xy), 0)))); + float cover = saturate(input.Colour.a) * length(n.xy); + float2 offset = n.xy * cover * cover * Reach; + return tex2D(SceneSampler, input.Position.xy * InverseSceneSize + offset); +} + +// DEFERRED_DYNAMIC_LIGHT, as the light it casts on the ground: ParticleRenderItem.DrawGroundLight draws one quad over the ground +// the scene's light particles reach (PointLights, the engine's list of this frame's), and each pixel adds up all of them, as CA +// adds up its deferred lights before its one tone map. Colours are each light's times its alpha at CA's share of the sun; +// LightScale brings them to the light a ground of VfxConventions.GroundAlbedo takes, on the models' sun scale, which goes to +// the screen through their tone map, so the ground reads as lit and not as paint laid on. +float LightScale = 0; +// As many as CommonShaderParameters.MaxPointLights. +float4 PointLights[64]; +float4 PointLightColours[64]; +int PointLightCount = 0; + +// A light of this colour at light.xyz, reaching light.w, on the ground at position. +float3 OnGround(float4 light, float3 colour, float3 position) +{ + float3 to = light.xyz - position; + float d = length(to); + return colour * saturate(to.y / max(d, 0.0001)) * LightFalloff(d / max(light.w, 0.0001)); +} + +float4 LightPS(VertexShaderOutput input) : COLOR +{ + float3 all = 0; + for (int i = 0; i < PointLightCount; i++) + all += OnGround(PointLights[i], PointLightColours[i].rgb, input.WorldPosition); + return float4(Gamma(saturate(Uncharted2ToneMapping(all * LightScale))), 1); +} + +technique ParticleDrawing +{ + pass P0 + { + VertexShader = compile VS_SHADERMODEL MainVS(); + PixelShader = compile PS_SHADERMODEL MainPS(); + } +}; + +technique Distortion +{ + pass P0 + { + VertexShader = compile VS_SHADERMODEL DistortionVS(); + PixelShader = compile PS_SHADERMODEL DistortionPS(); + } +}; + +technique Light +{ + pass P0 + { + VertexShader = compile VS_SHADERMODEL MainVS(); + PixelShader = compile PS_SHADERMODEL LightPS(); + } +}; diff --git a/GameWorld/ContentProject/Content/Shaders/Pbr/MetalRoughness/MetalRoughness_main.fx b/GameWorld/ContentProject/Content/Shaders/Pbr/MetalRoughness/MetalRoughness_main.fx index 243c2d712..e922ea912 100644 --- a/GameWorld/ContentProject/Content/Shaders/Pbr/MetalRoughness/MetalRoughness_main.fx +++ b/GameWorld/ContentProject/Content/Shaders/Pbr/MetalRoughness/MetalRoughness_main.fx @@ -100,6 +100,15 @@ float4 DefaultPixelShader(in PixelInputType input, bool bIsFrontFace : SV_IsFron // Apply global constant light colour so the scene lighting can be globally tuned hdr_linear_col *= Constant_LightColour; + // The VFX preview's light particles, each lit as the sun is, without the sun's environment light. + float3 reflectedView = reflect(normalizedViewDirection, slm_uncompressed.Normal); + for (int i = 0; i < PointLightCount; i++) + { + float3 toLight; + float3 pointColour = PointLightAt(i, input.worldPosition, toLight) * get_sun_colour() * unchartedSunFactor; + hdr_linear_col += standard_lighting_model_directional_light_SM4_private(pointColour, toLight, normalizedViewDirection, reflectedView, slm_uncompressed); + } + // Tone-map the pixel... //float3 ldr_linear_col = saturate(tone_map_linear_hdr_to_linear_ldr_reinhard(hdr_linear_col)); float3 ldr_linear_col = saturate(Uncharted2ToneMapping(hdr_linear_col)); @@ -109,7 +118,7 @@ float4 DefaultPixelShader(in PixelInputType input, bool bIsFrontFace : SV_IsFron // Combine all colours float3 color = ldr_linear_col + emissiveColour; - return float4(_gamma(color), 1.0f); + return float4(_gamma(color * ModelColour.rgb), ModelColour.a); } float4 EmissiveLayerPixelShader(in PixelInputType input, bool bIsFrontFace : SV_IsFrontFace) : SV_TARGET0 @@ -121,9 +130,26 @@ float4 EmissiveLayerPixelShader(in PixelInputType input, bool bIsFrontFace : SV_ if (UseAlpha == 1) alpha_test(material.diffuse.a); - return float4(emissiveColour, 1); + return float4(emissiveColour * ModelColour.rgb, ModelColour.a); +} + +// RenderingTechnique.Depth: the view depth the VFX preview's soft particles fade against, cut where the texture is cut. +float4 DepthPixelShader(in PixelInputType input) : SV_TARGET0 +{ + if (UseAlpha == 1) + alpha_test(GetMaterial(input).diffuse.a); + return float4(-mul(float4(input.worldPosition, 1), View).z, 0, 0, 1); } +technique DepthDrawing +{ + pass P0 + { + VertexShader = compile vs_5_0 MainVertexShader(); + PixelShader = compile ps_5_0 DepthPixelShader(); + } +}; + technique BasicColorDrawing { pass P0 diff --git a/GameWorld/ContentProject/Content/Shaders/Pbr/Shared/const_layout.hlsli b/GameWorld/ContentProject/Content/Shaders/Pbr/Shared/const_layout.hlsli index 7c696acde..0aadf9746 100644 --- a/GameWorld/ContentProject/Content/Shaders/Pbr/Shared/const_layout.hlsli +++ b/GameWorld/ContentProject/Content/Shaders/Pbr/Shared/const_layout.hlsli @@ -1,6 +1,8 @@ #ifndef CONST_LAYOUT_HLSLI #define CONST_LAYOUT_HLSLI +#include "light_falloff.hlsli" + float4x4 World; @@ -23,6 +25,25 @@ float light0_ambientFactor = 1; float3 TintColour = float3(1, 1, 1); +// A rigid particle's colour and alpha off its chain; white and opaque for everything else. +float4 ModelColour = float4(1, 1, 1, 1); + +// The VFX preview's light particles (CommonShaderParameters.PointLights): position and reach (xyz, w), and colour. +// As many as CommonShaderParameters.MaxPointLights. +float4 PointLights[64]; +float4 PointLightColours[64]; +int PointLightCount = 0; + +// Light i's colour where it reaches position, falling off as its pool on the ground does (LightFalloff), and the way to it. +// Each shader lights with it as it lights with the sun, on the sun's scale; the colours arrive already at CA's share of the +// sun (VfxConventions.LightShare), so a particle of colour 1 and alpha 1 is a 24th of it. +float3 PointLightAt(int i, float3 position, out float3 direction) +{ + float3 to = PointLights[i].xyz - position; + direction = normalize(to); + return PointLightColours[i].rgb * LightFalloff(length(to) / max(PointLights[i].w, 0.0001)); +} + // Textures Texture2D DiffuseTexture; Texture2D SpecularTexture; diff --git a/GameWorld/ContentProject/Content/Shaders/Pbr/Shared/light_falloff.hlsli b/GameWorld/ContentProject/Content/Shaders/Pbr/Shared/light_falloff.hlsli new file mode 100644 index 000000000..22af24338 --- /dev/null +++ b/GameWorld/ContentProject/Content/Shaders/Pbr/Shared/light_falloff.hlsli @@ -0,0 +1,13 @@ +#ifndef LIGHT_FALLOFF_HLSLI +#define LIGHT_FALLOFF_HLSLI + +// A light particle's falloff with d, its distance from the light over its reach (the particle's width), as CA's +// ps_main_deferred_dynamic_light falls off with g_vfx_deferred_dynamic_lights_smooth_falloff set: (1 - d)^2, nothing past +// its reach. The pool on the ground (Particle.fx LightPS) and the models (PointLightAt) both light with it. +float LightFalloff(float d) +{ + float f = saturate(1 - d); + return f * f; +} + +#endif // LIGHT_FALLOFF_HLSLI diff --git a/GameWorld/ContentProject/Content/Shaders/Pbr/SpecGloss/SpecGloss_main.fx b/GameWorld/ContentProject/Content/Shaders/Pbr/SpecGloss/SpecGloss_main.fx index 7544c136d..a375b94ac 100644 --- a/GameWorld/ContentProject/Content/Shaders/Pbr/SpecGloss/SpecGloss_main.fx +++ b/GameWorld/ContentProject/Content/Shaders/Pbr/SpecGloss/SpecGloss_main.fx @@ -83,14 +83,41 @@ float4 mainPS(in PixelInputType input, bool bIsFrontFace : SV_IsFrontFace) : SV_ // Apply global constant light colour so the scene lighting can be globally tuned hdr_linear_col *= Constant_LightColour; + + // The VFX preview's light particles, each lit as the sun is (scaled as standard_lighting_model_directional_light scales + // the sun), without the sun's environment light. + float3 reflectedView = reflect(normalizedViewDirection, standard_mat.Normal); + for (int i = 0; i < PointLightCount; i++) + { + float3 toLight; + float3 pointColour = PointLightAt(i, input.worldPosition, toLight) * directlightIntensity * 500.0f * LightMult; + hdr_linear_col += standard_lighting_model_directional_light_SM4_private(pointColour, toLight, normalizedViewDirection, reflectedView, standard_mat); + } // Tone-map the pixel... float3 ldr_linear_col = saturate(tone_map_linear_hdr_pixel_value(hdr_linear_col*exposure)); // Return gamma corrected value... - return float4( _gamma(ldr_linear_col), 1.0f); + return float4( _gamma(ldr_linear_col * ModelColour.rgb), ModelColour.a); +} + + +// RenderingTechnique.Depth: the view depth the VFX preview's soft particles fade against, cut where the texture is cut. +float4 DepthPixelShader(in PixelInputType input) : SV_TARGET0 +{ + if (UseAlpha == 1) + alpha_test(GetMaterial(input).diffuse.a); + return float4(-mul(float4(input.worldPosition, 1), View).z, 0, 0, 1); } +technique DepthDrawing +{ + pass P0 + { + VertexShader = compile vs_5_0 MainVertexShader(); + PixelShader = compile ps_5_0 DepthPixelShader(); + } +}; technique BasicColorDrawing { diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Components/Rendering/IRenderItem.cs b/GameWorld/GameWorldCore/GameWorld.Core/Components/Rendering/IRenderItem.cs index 0d8ca94ea..035cb9b87 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Components/Rendering/IRenderItem.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Components/Rendering/IRenderItem.cs @@ -10,13 +10,16 @@ public enum RenderBuckedId Selection, Font, Texture2D, - Grid + Grid, + Transparent, } public enum RenderingTechnique { Normal, Emissive, + /// The view depth of what is drawn, for soft particles to fade against; drawn only by what supports it. + Depth, } enum RasterizerStateEnum diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Components/Rendering/RenderEngineComponent.cs b/GameWorld/GameWorldCore/GameWorld.Core/Components/Rendering/RenderEngineComponent.cs index 8db38b5f5..5149939e7 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Components/Rendering/RenderEngineComponent.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Components/Rendering/RenderEngineComponent.cs @@ -4,6 +4,7 @@ using GameWorld.Core.Rendering; using GameWorld.Core.Services; using GameWorld.Core.Utility; +using GameWorld.Core.Vfx; using GameWorld.Core.WpfWindow; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; @@ -14,7 +15,9 @@ namespace GameWorld.Core.Components.Rendering { - public record SaveRenderImageSettings(string Name, bool OpenFolder, bool DrawLines, float ImageUpScaleFactor, string OutputFolder); + /// DrawLines false leaves out the lines and the grid; DrawOverlays false also the labels, axis widget, overlay text, + /// wireframe and selected faces, so the picture is saved alone. + public record SaveRenderImageSettings(string Name, bool OpenFolder, bool DrawLines, float ImageUpScaleFactor, string OutputFolder, bool DrawOverlays = true); public class RenderEngineComponent : BaseComponent, IDisposable { @@ -42,7 +45,11 @@ public class RenderEngineComponent : BaseComponent, IDisposable RenderTarget2D _normalRenderTarget; RenderTarget2D _emissiveRenderTarget; + // The glow is light, added whole whatever alpha the glow pass left: weighed by alpha it dropped a premultiplied additive + // particle's glow, which carries none, and a bloom with alpha hid the background behind it. + readonly BlendState _addLight = new() { ColorSourceBlend = Blend.One, ColorDestinationBlend = Blend.One, AlphaSourceBlend = Blend.Zero, AlphaDestinationBlend = Blend.One }; RenderTarget2D _screenRenderTarget; + RenderTarget2D? _depthRenderTarget; public SpriteBatch CommonSpriteBatch { get; private set; } public SpriteFont DefaultFont { get; private set; } @@ -147,6 +154,7 @@ public override void Draw(GameTime gameTime) var screenWidth = device.Viewport.Width; var screenHeight = device.Viewport.Height; var drawLines = _saveRenderImageSettings == null ? true: _saveRenderImageSettings.DrawLines; + var drawOverlays = _saveRenderImageSettings == null ? true : _saveRenderImageSettings.DrawOverlays; var imageUpScale = _saveRenderImageSettings == null ? 1 : _saveRenderImageSettings.ImageUpScaleFactor; if (screenWidth <= 10 || screenHeight <= 10) @@ -157,6 +165,12 @@ public override void Draw(GameTime gameTime) } var commonShaderParameters = CommonShaderParameterBuilder.Build(_camera, _sceneLightParameters, screenWidth, screenHeight); + // The VFX preview's light particles light the solid scene too, not only the ground their pools are drawn on. + // The first MaxPointLights in draw order, not the brightest or nearest; rank them if a scene needs more. + commonShaderParameters = commonShaderParameters with + { + PointLights = _renderItems[RenderBuckedId.Transparent].OfType().SelectMany(x => x.Lights()).Take(CommonShaderParameters.MaxPointLights).ToList() + }; var backgroundColour = ApplicationSettingsHelper.GetEnumAsColour(_applicationSettingsService.CurrentSettings.RenderEngineBackgroundColour); _normalRenderTarget = RenderTargetHelper.GetRenderTarget(device, _normalRenderTarget, imageUpScale, _graphicsResourceCreator, screenWidth, screenHeight); @@ -165,15 +179,20 @@ public override void Draw(GameTime gameTime) // Configure render targets var backBufferRenderTarget = device.GetRenderTargets()[0].RenderTarget as RenderTarget2D; + // Soft particles fade where they meet the solid scene, so with particles to draw, the scene's view depth is drawn first + // into a texture they read. It draws the solid scene a second time, so a frame with no particles skips it. + if (_renderItems[RenderBuckedId.Transparent].Count != 0) + commonShaderParameters = commonShaderParameters with { SceneDepth = DrawSceneDepth(device, commonShaderParameters) }; device.SetRenderTarget(_normalRenderTarget); device.Clear(Color.Transparent); - // 2D drawing - Render2DObjects(device, commonShaderParameters); + // 2D drawing: every item in these buckets is an overlay (labels, the axis widget, FPS and stats text) + if (drawOverlays) + Render2DObjects(device, commonShaderParameters); // 3D drawing - Normal scene device.DepthStencilState = DepthStencilState.Default; - Render3DObjects(commonShaderParameters, RenderingTechnique.Normal, drawLines); + Render3DObjects(commonShaderParameters, RenderingTechnique.Normal, drawLines, drawOverlays); // Draw the result to the backBuffer @@ -189,7 +208,7 @@ public override void Draw(GameTime gameTime) device.SetRenderTarget(_emissiveRenderTarget); device.Clear(Color.Transparent); device.DepthStencilState = DepthStencilState.Default; - Render3DObjects(commonShaderParameters, RenderingTechnique.Emissive, drawLines); + Render3DObjects(commonShaderParameters, RenderingTechnique.Emissive, drawLines, drawOverlays); // While re-sizing or changing view, there is a small chance that the // bloomRenderTarget could be null @@ -197,7 +216,7 @@ public override void Draw(GameTime gameTime) if (bloomRenderTarget != null) { device.SetRenderTarget(_screenRenderTarget); - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive); + spriteBatch.Begin(SpriteSortMode.Deferred, _addLight); spriteBatch.Draw(bloomRenderTarget, new Rectangle(0, 0, _screenRenderTarget.Width, _screenRenderTarget.Height), Color.White); spriteBatch.End(); } @@ -223,7 +242,9 @@ void HandleSaveLastFrame( ) using Stream stream = File.Create(folder + "\\" + _saveRenderImageSettings.Name + "_" + DateTime.Now.Ticks + ".png"); _deviceResolverComponent.Device.SetRenderTarget(null); - _screenRenderTarget.SaveAsPng(stream, _screenRenderTarget.Width, _screenRenderTarget.Height); + var pixels = new Color[_screenRenderTarget.Width * _screenRenderTarget.Height]; + _screenRenderTarget.GetData(pixels); + SavePng(stream, pixels, _screenRenderTarget.Width, _screenRenderTarget.Height); if (_saveRenderImageSettings.OpenFolder) DirectoryHelper.OpenOrFocusFolder(folder); @@ -237,6 +258,29 @@ void HandleSaveLastFrame( ) } } + /// The frame as a PNG. The frame is premultiplied, as the viewport draws it, and a PNG's alpha is straight. Additive + /// light (particles, bloom) adds colour and no alpha, so over the empty background it is saved with alpha from its brightness: + /// over black the picture is the viewport's, and a viewer that reads the alpha no longer drops the light. + public static void SavePng(Stream stream, Color[] premultiplied, int width, int height) + { + var bgra = new byte[premultiplied.Length * 4]; + for (var i = 0; i < premultiplied.Length; i++) + { + var c = premultiplied[i]; + var a = Math.Max(c.A, Math.Max(c.R, Math.Max(c.G, c.B))); + if (a == 0) + continue; + bgra[i * 4] = (byte)Math.Round(c.B * 255f / a); + bgra[i * 4 + 1] = (byte)Math.Round(c.G * 255f / a); + bgra[i * 4 + 2] = (byte)Math.Round(c.R * 255f / a); + bgra[i * 4 + 3] = a; + } + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(System.Windows.Media.Imaging.BitmapSource.Create( + width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null, bgra, width * 4))); + encoder.Save(stream); + } + private void Render2DObjects(GraphicsDevice device, CommonShaderParameters commonShaderParameters) { var spriteBatch = CommonSpriteBatch; @@ -251,15 +295,37 @@ private void Render2DObjects(GraphicsDevice device, CommonShaderParameters commo spriteBatch.End(); } - void Render3DObjects(CommonShaderParameters commonShaderParameters, RenderingTechnique renderingTechnique, bool drawLines) + RenderTarget2D DrawSceneDepth(GraphicsDevice device, CommonShaderParameters parameters) { - var device = _deviceResolverComponent.Device; + var (width, height) = (_normalRenderTarget.Width, _normalRenderTarget.Height); + if (_depthRenderTarget == null || _depthRenderTarget.Width != width || _depthRenderTarget.Height != height) + { + _graphicsResourceCreator.DisposeTracked(_depthRenderTarget); + _depthRenderTarget = _graphicsResourceCreator.CreateRenderTarget2D(width, height, false, SurfaceFormat.Single, DepthFormat.Depth24, 0, RenderTargetUsage.DiscardContents); + } + device.SetRenderTarget(_depthRenderTarget); + device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, new Vector4(float.MaxValue), 1, 0); + SetSquareViewport(device); + (device.BlendState, device.DepthStencilState, device.RasterizerState) = (BlendState.Opaque, DepthStencilState.Default, _rasterStates[RasterizerStateEnum.Normal]); + foreach (var item in _renderItems[RenderBuckedId.Normal].Concat(_renderItems[RenderBuckedId.Transparent])) + item.Draw(device, parameters, RenderingTechnique.Depth); + return _depthRenderTarget; + } + void SetSquareViewport(GraphicsDevice device) + { if (SquareViewport) { var size = Math.Min(device.Viewport.Width, device.Viewport.Height); device.Viewport = new Viewport((device.Viewport.Width - size) / 2, (device.Viewport.Height - size) / 2, size, size); } + } + + void Render3DObjects(CommonShaderParameters commonShaderParameters, RenderingTechnique renderingTechnique, bool drawLines, bool drawOverlays) + { + var device = _deviceResolverComponent.Device; + + SetSquareViewport(device); device.RasterizerState = _rasterStates[RasterizerStateEnum.Normal]; @@ -286,14 +352,33 @@ void Render3DObjects(CommonShaderParameters commonShaderParameters, RenderingTec item.Draw(device, commonShaderParameters, renderingTechnique); } - device.RasterizerState = _rasterStates[RasterizerStateEnum.Wireframe]; - foreach (var item in _renderItems[RenderBuckedId.Wireframe]) - item.Draw(device, commonShaderParameters, renderingTechnique); + if (drawOverlays) + { + device.RasterizerState = _rasterStates[RasterizerStateEnum.Wireframe]; + foreach (var item in _renderItems[RenderBuckedId.Wireframe]) + item.Draw(device, commonShaderParameters, renderingTechnique); - device.RasterizerState = _rasterStates[RasterizerStateEnum.SelectedFaces]; - foreach (var item in _renderItems[RenderBuckedId.Selection]) - item.Draw(device, commonShaderParameters, renderingTechnique); + device.RasterizerState = _rasterStates[RasterizerStateEnum.SelectedFaces]; + foreach (var item in _renderItems[RenderBuckedId.Selection]) + item.Draw(device, commonShaderParameters, renderingTechnique); + } + // Particles: after everything solid, in both passes so what lies over 1 blooms, reading depth without writing it, + // both faces; each item sets its blend. + if (_renderItems[RenderBuckedId.Transparent].Count != 0) + { + var blendState = device.BlendState; + device.DepthStencilState = DepthStencilState.DepthRead; + device.RasterizerState = RasterizerState.CullNone; + // The light every light particle puts on the ground, once for all of them and under them; it blooms no more than a + // lit model does. + if (renderingTechnique == RenderingTechnique.Normal) + ParticleRenderItem.DrawGroundLight(device, _resourceLibrary.GetStaticEffect(ShaderTypes.Particle), commonShaderParameters); + foreach (var item in _renderItems[RenderBuckedId.Transparent]) + item.Draw(device, commonShaderParameters, renderingTechnique); + device.BlendState = blendState; + device.DepthStencilState = DepthStencilState.Default; + } } @@ -304,9 +389,11 @@ public void Dispose() CommonSpriteBatch = _graphicsResourceCreator.DisposeTracked(CommonSpriteBatch); _bloomFilter.Dispose(); + _addLight.Dispose(); _normalRenderTarget = _graphicsResourceCreator.DisposeTracked(_normalRenderTarget); _emissiveRenderTarget = _graphicsResourceCreator.DisposeTracked(_emissiveRenderTarget); _screenRenderTarget = _graphicsResourceCreator.DisposeTracked(_screenRenderTarget); + _depthRenderTarget = _graphicsResourceCreator.DisposeTracked(_depthRenderTarget); _whiteTexture = _graphicsResourceCreator.DisposeTracked(_whiteTexture); _renderLines.Clear(); diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/CommonShaderParameters.cs b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/CommonShaderParameters.cs index dfa8c2d30..7ff3d3189 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/CommonShaderParameters.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/CommonShaderParameters.cs @@ -1,4 +1,5 @@ using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; namespace GameWorld.Core.Rendering { @@ -15,6 +16,38 @@ public record CommonShaderParameters( Vector3 LightColour, float ViewportHeight = 0, float ViewportWidth = 0 - ); + ) + { + /// Multiplies the model's colour and is its alpha: a rigid particle's colour off its chain. White and opaque by default. + public Vector4 ModelColour { get; init; } = Vector4.One; + + /// The VFX preview's light particles this frame, which light the solid scene as well as the ground; the shaders take + /// the first . + public IReadOnlyList PointLights { get; init; } = []; + + // A Bale Taurus standing carries 35. + public const int MaxPointLights = 64; + + /// Hands an effect that takes them (PointLightCount, PointLights, PointLightColours) the first + /// lights. + public static void SetPointLights(Effect effect, IReadOnlyList lights) + { + if (effect.Parameters["PointLightCount"] is not { } count) + return; + var first = lights.Take(MaxPointLights).ToArray(); + count.SetValue(first.Length); + if (first.Length == 0) + return; + effect.Parameters["PointLights"].SetValue(first.Select(x => new Vector4(x.Position, x.Radius)).ToArray()); + effect.Parameters["PointLightColours"].SetValue(first.Select(x => new Vector4(x.Colour, 1)).ToArray()); + } + + /// The solid scene's view depth per pixel, which soft particles fade against; null when the engine drew none. + public Texture2D? SceneDepth { get; init; } + } + + /// A light particle in the world: where it is, how far it reaches and its colour. Shaders/Pbr light a model with it as + /// Shaders/Particle's LightPS lights the ground under it, falling off as (1 - d^2/r^2)^2. + public readonly record struct PointLight(Vector3 Position, float Radius, Vector3 Colour); } diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Capabilities/CommonShaderParameters.cs b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Capabilities/CommonShaderParameters.cs index 06eb89aff..7aac99f40 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Capabilities/CommonShaderParameters.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Capabilities/CommonShaderParameters.cs @@ -18,6 +18,8 @@ public class CommonShaderParametersCapability : ICapability public float LightIntensityMult { get; set; } public Vector3 LightColour { get; set; } + public Vector4 ModelColour { get; set; } = Vector4.One; + public IReadOnlyList PointLights { get; set; } = []; public void Apply(Effect effect, IScopedResourceLibrary _) { @@ -29,6 +31,8 @@ public void Apply(Effect effect, IScopedResourceLibrary _) effect.Parameters["World"].SetValue(ModelMatrix); effect.Parameters["CameraPos"].SetValue(CameraPosition); effect.Parameters["Constant_LightColour"].SetValue(LightColour); + effect.Parameters["ModelColour"]?.SetValue(ModelColour); + CommonShaderParameters.SetPointLights(effect, PointLights); } public void Assign(CommonShaderParameters parameters, Matrix modelMatrix) @@ -44,6 +48,8 @@ public void Assign(CommonShaderParameters parameters, Matrix modelMatrix) DirLightRotationRadians_Y = parameters.DirLightRotationRadians_Y; LightIntensityMult = parameters.LightIntensityMult; LightColour = parameters.LightColour; + ModelColour = parameters.ModelColour; + PointLights = parameters.PointLights; } public ICapability Clone() @@ -59,6 +65,8 @@ public ICapability Clone() DirLightRotationRadians_Y = DirLightRotationRadians_Y, ModelMatrix = ModelMatrix, LightIntensityMult = LightIntensityMult, + ModelColour = ModelColour, + PointLights = PointLights, }; } diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/MetalRough/DefaultMaterial.cs b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/MetalRough/DefaultMaterial.cs index 17d2dc3e4..7738fa997 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/MetalRough/DefaultMaterial.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/MetalRough/DefaultMaterial.cs @@ -20,6 +20,7 @@ public DefaultMaterial(IScopedResourceLibrary resourceLibrary) ]; _renderingTechniqueMap[RenderingTechnique.Normal] = "BasicColorDrawing"; + _renderingTechniqueMap[RenderingTechnique.Depth] = "DepthDrawing"; } protected override void OnApply(Effect effect) diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/MetalRough/EmissiveMaterial.cs b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/MetalRough/EmissiveMaterial.cs index 451d2cd16..ba9b07b30 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/MetalRough/EmissiveMaterial.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/MetalRough/EmissiveMaterial.cs @@ -22,6 +22,7 @@ public EmissiveMaterial(IScopedResourceLibrary resourceLibrary) _renderingTechniqueMap[RenderingTechnique.Normal] = "BasicColorDrawing"; _renderingTechniqueMap[RenderingTechnique.Emissive] = "GlowDrawing"; + _renderingTechniqueMap[RenderingTechnique.Depth] = "DepthDrawing"; } protected override void OnApply(Effect effect) diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/SpecGloss/AdvancedRmvMaterial.cs b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/SpecGloss/AdvancedRmvMaterial.cs index 7007e9369..38dccc637 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/SpecGloss/AdvancedRmvMaterial.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/SpecGloss/AdvancedRmvMaterial.cs @@ -18,6 +18,7 @@ public AdvancedRmvMaterial(IScopedResourceLibrary resourceLibrary) ]; _renderingTechniqueMap[RenderingTechnique.Normal] = "BasicColorDrawing"; + _renderingTechniqueMap[RenderingTechnique.Depth] = "DepthDrawing"; } protected override CapabilityMaterial CreateCloneInstance() => new AdvancedRmvMaterial(_resourceLibrary); diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/SpecGloss/DefaultMaterial.cs b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/SpecGloss/DefaultMaterial.cs index 2a1947925..c73b2207d 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/SpecGloss/DefaultMaterial.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/Materials/Shaders/SpecGloss/DefaultMaterial.cs @@ -18,6 +18,7 @@ public DefaultMaterial(IScopedResourceLibrary resourceLibrary) ]; _renderingTechniqueMap[RenderingTechnique.Normal] = "BasicColorDrawing"; + _renderingTechniqueMap[RenderingTechnique.Depth] = "DepthDrawing"; } protected override CapabilityMaterial CreateCloneInstance() => new DefaultMaterial(_resourceLibrary); diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Services/ResourceLibary.cs b/GameWorld/GameWorldCore/GameWorld.Core/Services/ResourceLibary.cs index 7ff80a9d8..231d04994 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Services/ResourceLibary.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Services/ResourceLibary.cs @@ -18,7 +18,8 @@ public enum ShaderTypes BloomFilter, Grid, VertexPoint, - EdgeQuad + EdgeQuad, + Particle } public class ResourceLibrary @@ -62,6 +63,7 @@ public void Initialize(GraphicsDevice graphicsDevice, ContentManager content) LoadEffect("Shaders\\InstancingShader", ShaderTypes.GeometryInstance); LoadEffect("Shaders\\VertexPointShader", ShaderTypes.VertexPoint); LoadEffect("Shaders\\EdgeQuadShader", ShaderTypes.EdgeQuad); + var particle = LoadEffect("Shaders\\Particle", ShaderTypes.Particle); _pbrDiffuse = _content.Load("textures\\phazer\\DiffuseAmbientLightCubeMap"); _pbrSpecular= _content.Load("textures\\phazer\\SpecularAmbientLightCubemap"); @@ -73,6 +75,8 @@ public void Initialize(GraphicsDevice graphicsDevice, ContentManager content) sg.Parameters["tex_cube_diffuse"]?.SetValue(_pbrDiffuse); sg.Parameters["tex_cube_specular"]?.SetValue(_pbrSpecular); sg.Parameters["specularBRDF_LUT"]?.SetValue(_pbrLut); + // A lit particle takes the sky's light off the cube a model takes it off. + particle.Parameters["AmbientCube"]?.SetValue(_pbrDiffuse); } catch (Exception e) { diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/EmitterSettings.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/EmitterSettings.cs new file mode 100644 index 000000000..123c32bdb --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/EmitterSettings.cs @@ -0,0 +1,479 @@ +using Microsoft.Xna.Framework; +using Shared.GameFormats.Vfx; + +namespace GameWorld.Core.Vfx +{ + public enum SimulationSpace { World, LocalPositionRotation, LocalPositionOnly } + + // The first six come from vs_translation_velocity_v2's orientation_mode; Upright, Flat and Object3D are vs_translation's + // restriction 1, 2 and 3 (CA's "Vertical, Horizontal, 3D"). VelocityAligned is orientation_mode 5, "Camera-aligned with + // stretching factor", and VelocityAxis its mode 2, "Camera-facing velocity-aligned". + public enum QuadOrientation { CameraFacing, VelocityAligned, Horizontal, Vertical, LocalHorizontal, LocalVertical, Upright, Flat, Object3D, VelocityAxis } + + /// ps_lighting: how much sunlight small (Rayleigh) and large (Henyey-Greenstein) particles scatter, the large ones' + /// directionality (g), how far the light is drawn toward emissive, and the average size the light crosses. + public record ParticleLighting(float Small, float Large, float Directionality, float Emissive, float AverageSize); + + /// A particle event's effect, ready to run: a copy per particle, started at its birth or its death plus Offset seconds, + /// following what of the particle the flags say (ParticleSimulator). + public record ParticleEvent(bool OnDeath, IReadOnlyList Emitters, bool TrackPosition = false, bool TrackScale = false, + bool TrackColour = false, bool TrackAlpha = false, bool TrackLifetime = false, VfxEventTurn Turn = VfxEventTurn.None, float Offset = 0); + + /// + /// One emitter, read into what ParticleSimulator and ParticleRenderItem use. Its setters let a test + /// build one directly; From reads one out of a resolved emitter, and lists in Skipped what the preview + /// does not simulate, for the log. + /// + public class EmitterSettings + { + public string Name { get; set; } = ""; + public bool Draws { get; set; } = true; + public List Skipped { get; } = []; + + // Placement and timing + public Matrix ReferenceTransform { get; set; } = Matrix.Identity; + public float StartOffset { get; set; } + public (float Min, float Max) StartTime { get; set; } + public (float Min, float Max) LifeTime { get; set; } = (1, 1); + public (float Min, float Max) EmitterLifeBias { get; set; } + public bool InfiniteLife { get; set; } + public float SpeedMultiplier { get; set; } = 1; + public SimulationSpace Space { get; set; } + public VfxCurve? EmitterPosition { get; set; } + public VfxCurve? EmitterOrientation { get; set; } + public bool LoopPosition { get; set; } + public bool LoopOrientation { get; set; } + + // Spawning + public VfxCurve? Rate { get; set; } + public bool LoopRate { get; set; } + public (float Min, float Max) RateScale { get; set; } = (1, 1); + public (float Min, float Max)? BurstInterval { get; set; } + public (float Min, float Max) BurstLength { get; set; } + public int MaxParticles { get; set; } = ParticleSimulator.ParticleCap; + public VfxCurve? SpawnBox { get; set; } + public VfxCurve? SpawnRing { get; set; } + public bool LoopArea { get; set; } + public Vector2 SpawnAngle { get; set; } + public Vector2 SpawnArc { get; set; } + public (float Min, float Max) Spread { get; set; } = (90, 90); + /// Metres the emitter moves per extra particle, on top of the rate; 0 is none. + public float SpawnSpacing { get; set; } + /// MaximumVelocity: the most a particle's speed reaches, in m/s; 0 for no cap. + public float MaxSpeed { get; set; } + /// SkipParticlePositionUpdate: particles stay where they spawned; their velocity still aligns and stretches them. + public bool SkipPositionUpdate { get; set; } + /// SpawnInheritedSpeed: the share of the emitter's own velocity a particle starts with. + public float InheritedSpeed { get; set; } + /// SpawnInheritedDirectionBlend: VfxConventions.InheritDirection. + public float InheritedDirectionBlend { get; set; } + /// OnParticleSpawn and OnParticleDeath: the effects each particle starts at its birth and its death. + public IReadOnlyList Events { get; set; } = []; + /// ParticlesSpawnOnTerrain: particles start on the ground. + public bool SpawnOnTerrain { get; set; } + /// EnableCollisions, with CollisionBouncingFactor and CollisionDampingFactor drawn per particle. + public bool Collisions { get; set; } + public (float Min, float Max) Bounce { get; set; } + public (float Min, float Max) Damping { get; set; } + /// vs_flip_uv: the chance a particle's texture is mirrored left to right, and top to bottom. + public float FlipU { get; set; } + public float FlipV { get; set; } + /// vs_flip_uv uv_orientation: a quarter turn of the texture, 1 counter-clockwise, -1 clockwise. + public int UvTurn { get; set; } + /// CullDistances and ParticleCullDistances, metres from the camera: fade in over x..y, out over z..w. + public Vector4 EmitterCull { get; set; } = NoCull; + public Vector4 ParticleCull { get; set; } = NoCull; + /// vs_rendersize_limiter pixelsize_limit: the fewest pixels a particle is drawn across. + public float MinPixels { get; set; } + /// ps_soft_edge soft_factor, metres (0: a hard edge), and scale_strength. + public float SoftEdge { get; set; } + public float SoftStrength { get; set; } = 1; + static readonly Vector4 NoCull = new(0, 0, float.MaxValue, float.MaxValue); + + // A particle's start + public VfxCurve? InitialSpeed { get; set; } + public VfxCurve? InitialScale { get; set; } + public VfxCurve? InitialAlpha { get; set; } + public VfxCurve? ParticleLife { get; set; } + public bool LoopSpeed { get; set; } + public bool LoopInitial { get; set; } + public bool LoopLife { get; set; } + public (float Min, float Max) LifeBias { get; set; } + public Vector4 TintMin { get; set; } = Vector4.One; + public Vector4 TintMax { get; set; } = Vector4.One; + public (float Min, float Max) Angle { get; set; } + public (float Min, float Max) Spin { get; set; } + public float SpinSign { get; set; } = 1; + + // Over a particle's life + public VfxCurve? Scale { get; set; } + public VfxCurve? Deceleration { get; set; } + public VfxCurve? SpinDeceleration { get; set; } + public VfxCurve?[] Forces { get; set; } = []; + public VfxCurve? Alpha { get; set; } + /// vs_vertex_alpha's alpha_START_offset and alpha_END_offset (VfxConventions.AlphaLife); zero without them. + public Vector2 AlphaStartOffset { get; set; } + public Vector2 AlphaEndOffset { get; set; } + public VfxCurve? Colour { get; set; } + /// ps_emissive's emissive_mul; 1 without one. + public float EmissiveMul { get; set; } = 1; + public VfxCurve? Stretch { get; set; } + + // Look + public string? Texture { get; set; } + public bool Premultiplied { get; set; } = true; + /// ps_texture's overlay_colour: the colour is overlaid on the texture instead of multiplied in. + public bool Overlay { get; set; } + public float AdditiveMul { get; set; } + /// ps_normal: the ps_texture normal map that shapes a lit chain's light; null without ps_normal. + public string? NormalMap { get; set; } + /// ps_lighting's settings; null without one. + public ParticleLighting? Lighting { get; set; } + /// Drawn lit: ps_lighting on a chain blended by alpha that lies over the scene. An additive chain stays unlit, as the + /// preview's additive reading (VfxConventions.Blend) is not CA's, and a decal paints what lies under it. + public bool Lit => Lighting != null && AdditiveMul == 0 && !Decal && !Light && !Distortion; + public Vector2 Pivot { get; set; } = new(0.5f, 0.5f); + public QuadOrientation Orientation { get; set; } + /// enable_trail_mode on the render stack: the particles drawn as one strip, not a quad each. + public bool Trail { get; set; } + public float StretchCap { get; set; } + public int CellsX { get; set; } = 1; + public int CellsY { get; set; } = 1; + public bool Animated { get; set; } + public bool FlipbookLoop { get; set; } = true; + public Vector2 Loops { get; set; } = Vector2.One; + public bool FixedSpeed { get; set; } + public bool CellBlend { get; set; } + /// vs_animated_texture's row_restricted: the strip is one row of the sheet, row_restriction. + public bool RowRestricted { get; set; } + public int Row { get; set; } + /// vs_animated_texture's randomize_row: each particle starts on a random row. + public bool RandomRow { get; set; } + /// The stack draws onto what lies under it (DEFERRED_DECAL, DEFERRED_DECAL_EMISSIVE, SEA_OVERLAY). + public bool Decal { get; set; } + /// The stack is DISTORTION: the chain's normal map shifts the scene behind it. + public bool Distortion { get; set; } + public bool Light { get; set; } + public VfxVectorField? Field { get; set; } + public VfxCurve? FieldSize { get; set; } + public VfxCurve? FieldStrength { get; set; } + public VfxCurve? FieldTightness { get; set; } + public VfxCurve? FieldPosition { get; set; } + public VfxCurve? FieldOrientation { get; set; } + /// Vectorfield{Size,Position,Orientation}LoopAnim: those curves wrap over the emitter's life, as EmitterPositionLoopAnim's does. + public bool LoopFieldSize { get; set; } + public bool LoopFieldPosition { get; set; } + public bool LoopFieldOrientation { get; set; } + /// continuous_rigid: the models a particle may be, and its orientation and spin about X and Y (Angle and Spin are Z). + public IReadOnlyList Rigid { get; set; } = []; + /// ParticleRelativeSpawnOrientation RELATIVE_TO_TERRAIN_NORMAL: the model, or an Object3D quad, stands on the ground (VfxConventions.RigidFrame). + public bool RigidOnTerrain { get; set; } + /// ParticleRelativeSpawnDirection RELATIVE_TO_TERRAIN_NORMAL: the spawn direction is taken on the ground, keeping the emitter's heading. + public bool DirectionOnTerrain { get; set; } + public (Vector2 Min, Vector2 Max) TurnXy { get; set; } + public (Vector2 Min, Vector2 Max) SpinXy { get; set; } + public Vector2 SpinSignXy { get; set; } = Vector2.One; + /// ps_alpha_crush: a particle's crush level over its life, and whether it cuts sharp. + public VfxCurve? AlphaCrush { get; set; } + public bool CrushSharp { get; set; } + /// ps_gradient: the colour and alpha strips a texel's value picks from, and alpha_mapping. + public VfxCurve? Gradient { get; set; } + public VfxCurve? GradientAlpha { get; set; } + public bool GradientMapsAlpha { get; set; } + public int Cell { get; set; } + public bool RandomCell { get; set; } + public int AtlasX { get; set; } = 1; + public int AtlasY { get; set; } = 1; + public int AtlasCell { get; set; } + + // The 18 fragments vanilla uses. + static readonly HashSet s_knownFragments = + [ + "vs_vertex_alpha", "vs_colour", "ps_texture", "ps_soft_edge", "ps_lighting", "vs_translation", "vs_cell_texture", + "vs_translation_velocity_v2", "vs_animated_texture", "vs_flip_uv", "ps_normal", "vs_rendersize_limiter", "ps_alpha_crush", + "ps_gradient", "ps_emissive", "ps_custom_shader", "ps_metal_roughness", "vs_translation_screen_space_wrap", + ]; + + public static EmitterSettings From(ResolvedEmitter resolved) + { + var properties = resolved.Emitter.Properties; + var o = resolved.Override; + // The file is in the name, so a log line says where a broken property lives. + var s = new EmitterSettings { Name = resolved.File + "/" + resolved.Emitter.Id }; + + VfxProperty? P(string name) => properties.GetValueOrDefault(name); + bool Flag(string name) => P(name)?.IsTrue ?? false; + float Number(string name, float fallback) => P(name)?.Number("value", fallback) ?? fallback; + (float, float) Range(string name, (float, float) fallback) => P(name) is { } p ? (p.Number("min"), p.Number("max")) : fallback; + Vector2 Xy(string name) => P(name) is { } p ? new Vector2(p.Number("x"), p.Number("y")) : Vector2.Zero; + float Z(string name) => P(name)?.Number("z") ?? 0; + + s.ReferenceTransform = resolved.ReferenceTransform; + s.Trail = resolved.TrailMode; + s.StartOffset = resolved.StartOffset; + s.SpeedMultiplier = resolved.SpeedMultiplier * Number("SimulationSpeedMultiplier", 1); + s.StartTime = Range("StartTime", (0, 0)); + s.LifeTime = Range("LifeTime", (1, 1)); + s.EmitterLifeBias = (o.EmitterLifetimeBiasMin, o.EmitterLifetimeBiasMax); + s.InfiniteLife = resolved.InfiniteLife && Flag("InfiniteLife"); + s.Space = P("ParticleSimulationSpace")?.Value switch + { + "LOCAL_SPACE_POSITION_ROTATION" => SimulationSpace.LocalPositionRotation, + "LOCAL_SPACE_POSITION_ONLY" => SimulationSpace.LocalPositionOnly, + _ => SimulationSpace.World, + }; + s.EmitterPosition = ParseCurve(s, P("EmitterPosition")); + s.EmitterOrientation = ParseCurve(s, P("EmitterOrientation")); + s.LoopPosition = Flag("EmitterPositionLoopAnim"); + s.LoopOrientation = Flag("EmitterOrientationLoopAnim"); + + s.Rate = ParseCurve(s, P("EmissionRateCurve")); + s.LoopRate = Flag("EmissionLoopAnim"); + s.RateScale = (o.SpawnRateScaleMin, o.SpawnRateScaleMax); + if (P("BurstInterval") != null) + { + s.BurstInterval = Range("BurstInterval", (0, 0)); + s.BurstLength = Range("BurstIntervalLength", (0, 0)); + } + var maximum = (int)Number("MaximumParticles", ParticleSimulator.ParticleCap); + s.MaxParticles = maximum > 0 ? Math.Min(maximum, ParticleSimulator.ParticleCap) : ParticleSimulator.ParticleCap; + s.SpawnBox = ParseCurve(s, P("SpawnAreaBox")); + s.SpawnRing = ParseCurve(s, P("SpawnAreaCurve")); + s.LoopArea = Flag("SpawnAreaLoopAnim"); + s.SpawnAngle = P("SpawnAngle") == null ? VfxConventions.EveryAzimuth : Xy("SpawnAngle"); + s.SpawnArc = Xy("SpawnArc"); + s.Spread = Range("SpawnSpread", (90, 90)); + s.SkipPositionUpdate = Flag("SkipParticlePositionUpdate"); + s.SpawnSpacing = Math.Max(0, Number("SpawnSpacing", 0)); + // CA ships no source for it; read as its name says, on 31 vanilla emitters whose particles start still. + s.MaxSpeed = Math.Max(0, Number("MaximumVelocity", 0)); + + s.InitialSpeed = ParseCurve(s, P("ParticleInitialSpeed")); + s.InitialScale = ParseCurve(s, P("ParticleInitialScale")); + s.InitialAlpha = ParseCurve(s, P("ParticleInitialAlpha")); + s.ParticleLife = ParseCurve(s, P("ParticleLifeTime")); + s.LoopSpeed = Flag("ParticleInitialSpeedLoopAnim"); + s.LoopInitial = Flag("ParticleInitialParamLoopAnim"); + s.LoopLife = Flag("ParticleLifeTimeLoopAnim"); + s.LifeBias = (o.ParticleLifetimeBiasMin, o.ParticleLifetimeBiasMax); + s.TintMin = o.TintMin; + s.TintMax = o.TintMax; + s.Angle = (Z("ParticleOrientationMin"), Z("ParticleOrientationMax")); + s.Spin = (Z("ParticleSpin_Min"), Z("ParticleSpin_Max")); + s.SpinSign = VfxConventions.SpinSign(P("ParticleSpinDirectionZ")?.Value); + s.TurnXy = (Xy("ParticleOrientationMin"), Xy("ParticleOrientationMax")); + s.SpinXy = (Xy("ParticleSpin_Min"), Xy("ParticleSpin_Max")); + s.SpinSignXy = new Vector2(VfxConventions.SpinSign(P("ParticleSpinDirectionX")?.Value), VfxConventions.SpinSign(P("ParticleSpinDirectionY")?.Value)); + + s.Scale = ParseCurve(s, P("ParticleScale")); + s.Deceleration = ParseCurve(s, P("ParticleDeceleration")); + s.SpinDeceleration = ParseCurve(s, P("ParticleSpinDeceleration")); + s.Forces = [ParseCurve(s, P("ForceA")), ParseCurve(s, P("ForceB")), ParseCurve(s, P("ForceC"))]; + + s.RigidOnTerrain = P("ParticleRelativeSpawnOrientation")?.Value == "RELATIVE_TO_TERRAIN_NORMAL"; + // CA ships no source for it; read as its orientation sibling is, on 249 vanilla emitters. + s.DirectionOnTerrain = P("ParticleRelativeSpawnDirection")?.Value == "RELATIVE_TO_TERRAIN_NORMAL"; + if (resolved.Emitter.Type == "continuous_rigid") + { + s.Rigid = resolved.Emitter.Rigid ?? []; + if (s.Rigid.Count == 0) + NotDrawn(s, "its RigidData names no model, so there is nothing to draw"); + } + else if (resolved.Emitter.Type != "continuous_mesh") + NotDrawn(s, $"emitter type {resolved.Emitter.Type} is not drawn"); + if (resolved.SceneViewType is "DEFERRED_DECAL" or "DEFERRED_DECAL_EMISSIVE" or "SEA_OVERLAY") + s.Decal = true; + else if (resolved.SceneViewType == "DISTORTION") + s.Distortion = true; + else if (resolved.SceneViewType == "DEFERRED_DYNAMIC_LIGHT") + (s.Decal, s.Light) = (true, true); + else if (resolved.SceneViewType is not (null or "FULLRES_DEFERRED_ALPHA" or "HALFRES_DEFERRED_ALPHA")) + NotDrawn(s, $"scene_view_type {resolved.SceneViewType} is not drawn"); + if (Flag("AllowEmitFromMesh")) + s.Skipped.Add("emit from mesh is not simulated"); + if (P("VectorFieldStrId")?.Value is { Length: > 0 } field) + { + s.Field = resolved.VectorField; + if (s.Field == null) + s.Skipped.Add($"vector field {field} is not in the loaded packs or is not a half-float volume, so it is not simulated"); + s.FieldSize = ParseCurve(s, P("VectorFieldSize")); + s.FieldStrength = ParseCurve(s, P("VectorFieldStrength")); + s.FieldTightness = ParseCurve(s, P("VectorFieldTightness")); + s.FieldPosition = ParseCurve(s, P("VectorFieldPosition")); + s.FieldOrientation = ParseCurve(s, P("VectorFieldOrientation")); + s.LoopFieldSize = Flag("VectorfieldSizeLoopAnim"); + s.LoopFieldPosition = Flag("VectorfieldPositionLoopAnim"); + s.LoopFieldOrientation = Flag("VectorfieldOrientationLoopAnim"); + } + if (Number("EffectedByWindFactor", 0) != 0 || Number("WindVarianceFactor", 0) != 0) + s.Skipped.Add("wind is not simulated"); + s.InheritedSpeed = Number("SpawnInheritedSpeed", 0); + s.InheritedDirectionBlend = Number("SpawnInheritedDirectionBlend", 0); + s.Events = (resolved.Events ?? []).Where(x => x.Emitters.Count > 0) + .Select(x => new ParticleEvent(x.Event.OnDeath, x.Emitters.Select(From).ToList(), x.Event.TrackPosition, x.Event.TrackScale, + x.Event.TrackColour, x.Event.TrackAlpha, x.Event.TrackLifetime, x.Event.Turn, x.Event.Offset)) + .ToList(); + if (resolved.RefSpawnSpeed is { } refSpawnSpeed) + s.Skipped.Add(FormattableString.Invariant($"its file's ref_spawn_speed {refSpawnSpeed} is not read")); + s.SpawnOnTerrain = Flag("ParticlesSpawnOnTerrain"); + s.Collisions = Flag("EnableCollisions"); + s.Bounce = Range("CollisionBouncingFactor", (0, 0)); + s.Damping = Range("CollisionDampingFactor", (0, 0)); + Vector4 Cull(string name, Vector4 fallback) => P(name) is { } p ? new Vector4(p.Number("x"), p.Number("y"), p.Number("z"), p.Number("w")) : fallback; + s.EmitterCull = Cull("CullDistances", s.EmitterCull); + s.ParticleCull = Cull("ParticleCullDistances", s.ParticleCull); + if (Range("SpreadArc", (0, 0)) != (0, 0)) + s.Skipped.Add("SpreadArc is not read"); + + ReadChain(s, resolved.Chain, resolved.MaterialTexture); + // A decal paints the ground's colour, not light, so its texture is laid over by its alpha whatever the chain says: + // wh3_dlc20_kho_crack_01, flagged premultiplied, bleeds brown into its clear texels. + if (resolved.SceneViewType == "DEFERRED_DECAL") + s.Premultiplied = false; + return s; + } + + static void ReadChain(EmitterSettings s, VfxBehaviourChain? chain, string? materialTexture) + { + // A rigid particle draws its model, chain or no chain. + if (chain == null) + { + if (s.Rigid.Count == 0) + NotDrawn(s, "no behaviour chain block at this quality tier, so nothing to draw"); + return; + } + foreach (var modifier in chain.Modifiers.Where(x => !s_knownFragments.Contains(x.Fragment))) + s.Skipped.Add($"fragment {modifier.Fragment} is not one of the 18 vanilla uses, so it is ignored"); + + float Number(VfxModifier? m, string name, float fallback) => m?.Properties.GetValueOrDefault(name)?.Number("value", fallback) ?? fallback; + bool Flag(VfxModifier? m, string name) => m?.Properties.GetValueOrDefault(name)?.IsTrue ?? false; + + var texture = chain.Find("ps_texture"); + // A light draws no texture, only the pool it casts. + s.Texture = !s.Light && texture?.Textures.GetValueOrDefault("diffuse") is { Length: > 0 } path ? path : null; + if (s.Distortion) + { + s.Texture = texture?.Textures.GetValueOrDefault("normal") is { Length: > 0 } normal ? normal : null; + if (s.Texture == null) + NotDrawn(s, "its DISTORTION chain names no normal map, so it has nothing to shift the scene by"); + } + else if (texture == null && chain.Find("ps_custom_shader") is { } custom) + { + var material = custom.Properties.GetValueOrDefault("material")?.Attributes.GetValueOrDefault("path"); + s.Texture = materialTexture; + if (materialTexture == null) + NotDrawn(s, $"fragment ps_custom_shader draws through {material}, which names no colour texture in the loaded packs"); + else + s.Skipped.Add($"fragment ps_custom_shader: {material} is drawn with its colour texture {materialTexture}, not its own shader"); + } + else if (texture == null && s.Draws && s.Rigid.Count == 0 && !s.Light) + NotDrawn(s, "the chain has no ps_texture, so nothing is drawn: in vanilla only sound carriers lack one"); + s.Premultiplied = texture?.Properties.GetValueOrDefault("pre_multiplied_alpha")?.IsTrue ?? true; + s.Overlay = texture?.Properties.GetValueOrDefault("overlay_colour")?.IsTrue ?? false; + if (!s.Distortion && chain.Find("ps_normal") != null && texture?.Textures.GetValueOrDefault("normal") is { Length: > 0 } normalMap) + s.NormalMap = normalMap; + var lighting = chain.Find("ps_lighting"); + s.AdditiveMul = Number(lighting, "additive_blend_mul", 0); + // Missing settings take the defaults fx/particle_vfx_library.hlsl gives them. + if (lighting != null) + s.Lighting = new ParticleLighting(Number(lighting, "small_particles", 0.1f), Number(lighting, "large_particles", 0.5f), + Number(lighting, "directionality", 0.4f), Number(lighting, "emissive", 0), Number(lighting, "average_size", 0.6f)); + s.MinPixels = Number(chain.Find("vs_rendersize_limiter"), "pixelsize_limit", 0); + s.EmissiveMul = Number(chain.Find("ps_emissive"), "emissive_mul", 1); + var crush = chain.Find("ps_alpha_crush"); + s.AlphaCrush = ParseCurve(s, crush?.Properties.GetValueOrDefault("curve_alpha_crush")); + s.CrushSharp = Flag(crush, "sharp_transition"); + var gradient = chain.Find("ps_gradient"); + s.Gradient = ParseCurve(s, gradient?.Properties.GetValueOrDefault("curve_colour")); + s.GradientAlpha = ParseCurve(s, gradient?.Properties.GetValueOrDefault("curve_alpha")); + s.GradientMapsAlpha = Flag(gradient, "alpha_mapping"); + var soft = chain.Find("ps_soft_edge"); + s.SoftEdge = Number(soft, "soft_factor", 0); + s.SoftStrength = Number(soft, "scale_strength", 1); + var flip = chain.Find("vs_flip_uv"); + s.FlipU = Number(flip, "flip_u", 0); + s.FlipV = Number(flip, "flip_v", 0); + s.UvTurn = flip?.Properties.GetValueOrDefault("uv_orientation")?.Value switch { "90_degrees_ccw" => 1, "90_degrees_cw" => -1, _ => 0 }; + var vertexAlpha = chain.Find("vs_vertex_alpha"); + s.Alpha = ParseCurve(s, vertexAlpha?.Properties.GetValueOrDefault("curve_alpha")); + s.AlphaStartOffset = vertexAlpha?.Properties.GetValueOrDefault("alpha_START_offset")?.Float2(Vector2.Zero) ?? Vector2.Zero; + s.AlphaEndOffset = vertexAlpha?.Properties.GetValueOrDefault("alpha_END_offset")?.Float2(Vector2.Zero) ?? Vector2.Zero; + s.Colour = ParseCurve(s, chain.Find("vs_colour")?.Properties.GetValueOrDefault("curve_colour")); + + var velocity = chain.Find("vs_translation_velocity_v2"); + var translation = velocity ?? chain.Find("vs_translation"); + s.Pivot = new Vector2(Number(translation, "pivot_x", 0.5f), Number(translation, "pivot_y", 0.5f)); + s.Stretch = ParseCurve(s, velocity?.Properties.GetValueOrDefault("velocity_stretch_mul")); + s.StretchCap = Number(velocity, "velocity_stretch_cap", 0); + var mode = velocity?.Properties.GetValueOrDefault("orientation_mode")?.Value; + (s.Orientation, var known) = mode switch + { + null => (QuadOrientation.CameraFacing, true), + "Camera-aligned with stretching factor" => (QuadOrientation.VelocityAligned, true), + "Camera-facing velocity-aligned" => (QuadOrientation.VelocityAxis, true), + "Horizontally-oriented" => (QuadOrientation.Horizontal, true), + "Vertically-oriented" => (QuadOrientation.Vertical, true), + "Particle only rotating around local Y (horizontal)" => (QuadOrientation.LocalHorizontal, true), + "Particle only rotating around local Y (vertical)" => (QuadOrientation.LocalVertical, true), + _ => (QuadOrientation.CameraFacing, false), + }; + if (!known) + s.Skipped.Add($"orientation_mode \"{mode}\" is drawn camera-facing"); + // Without a velocity modifier the quad turns by vs_translation's restriction: CA's particle_vfx_library.hlsl names + // 0 to 3 "CameraFacing, Vertical, Horizontal, 3D". + if (velocity == null) + s.Orientation = (int)Number(translation, "restriction", 0) switch + { + 1 => QuadOrientation.Upright, + 2 => QuadOrientation.Flat, + 3 => QuadOrientation.Object3D, + _ => QuadOrientation.CameraFacing, + }; + + var animated = chain.Find("vs_animated_texture"); + var cells = chain.Find("vs_cell_texture"); + var atlas = animated ?? cells; + s.AtlasX = Math.Max(1, (int)Number(atlas, "atlas_number_of_cells_x", 1)); + s.AtlasY = Math.Max(1, (int)Number(atlas, "atlas_number_of_cells_y", 1)); + s.AtlasCell = (int)Number(atlas, "atlas_cell", 0); + if (animated != null) + { + var count = animated.Properties.GetValueOrDefault("no_cells_xy")?.Float2(Vector2.One) ?? Vector2.One; + s.CellsX = Math.Max(1, (int)count.X); + s.CellsY = Math.Max(1, (int)count.Y); + s.Animated = true; + s.FlipbookLoop = Flag(animated, "loop"); + s.Loops = animated.Properties.GetValueOrDefault("no_loops")?.Float2(Vector2.One) ?? Vector2.One; + s.FixedSpeed = animated.Properties.GetValueOrDefault("time_mode")?.Value == "fixed_animation_speed"; + s.CellBlend = Flag(animated, "cell_blend"); + s.RowRestricted = Flag(animated, "row_restricted"); + s.Row = (int)Number(animated, "row_restriction", 0); + s.RandomRow = Flag(animated, "randomize_row"); + s.RandomCell = Flag(animated, "randomize_cell"); + } + else if (cells != null) + { + s.CellsX = Math.Max(1, (int)Number(cells, "number_of_cells_x", 1)); + s.CellsY = Math.Max(1, (int)Number(cells, "number_of_cells_y", 1)); + s.Cell = (int)Number(cells, "cell", 0); + s.RandomCell = Flag(cells, "randomize_cell"); + } + } + + static VfxCurve? ParseCurve(EmitterSettings s, VfxProperty? property) + { + if (property == null) + return null; + var curve = VfxCurve.Parse(property.Text); + if (curve == null) + s.Skipped.Add($"{property.Name} did not parse, so its default is used"); + return curve; + } + + static void NotDrawn(EmitterSettings s, string why) + { + s.Draws = false; + s.Skipped.Add(why); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/ParticleRenderItem.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/ParticleRenderItem.cs new file mode 100644 index 000000000..6381e22eb --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/ParticleRenderItem.cs @@ -0,0 +1,571 @@ +using GameWorld.Core.Components.Rendering; +using GameWorld.Core.Rendering; +using GameWorld.Core.Rendering.RenderItems; +using GameWorld.Core.Utility; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Shared.Core.PackFiles; +using System.Runtime.CompilerServices; + +namespace GameWorld.Core.Vfx +{ + /// + /// Draws one effect's particles: a camera-built quad per particle, or one strip for a trail-mode emitter, one draw + /// per emitter, emitters and particles back to front, through Shaders/Particle.fx. The + /// app's emissive pass gets what lies over 1 of each colour, and blooms it. + /// Textures come from the editor's VfxTextureCache on first draw; a missing one draws white. + /// + public class ParticleRenderItem : IRenderItem, IDisposable + { + private readonly ParticleSimulator _simulator; + private readonly VfxTextureCache _textureCache; + private readonly Func _shader; + private readonly VfxMeshCache? _meshes; + private Texture2D? _white; + private RenderTarget2D? _scene; + private SpriteBatch? _batch; + + public Matrix World { get; set; } = Matrix.Identity; + public bool IsDisposed { get; private set; } + + /// shader: Shaders/Particle, asked for at draw time (ResourceLibrary's ShaderTypes.Particle in the app). + /// meshes: continuous_rigid's models; without it, rigid emitters draw nothing. + public ParticleRenderItem(ParticleSimulator simulator, VfxTextureCache textures, Func shader, VfxMeshCache? meshes = null) + { + _shader = shader; + _meshes = meshes; + _simulator = simulator; + _textureCache = textures; + } + + /// The view's position and axes, moved into the effect's model space. PixelScale: the pixels one metre covers + /// one metre from the camera, 0 when the view's height is not known. ToWorld: the effect's matrix, null for none. + public readonly record struct CameraAxes(Vector3 Position, Vector3 Right, Vector3 Up, Vector3 Forward, float PixelScale = 0, Matrix? ToWorld = null) + { + public static CameraAxes From(CommonShaderParameters parameters, Matrix world, int viewportHeight = 0) + { + var toModel = Matrix.Invert(world); + var v = parameters.View; + Vector3 Axis(Vector3 axis) => Vector3.Normalize(Vector3.TransformNormal(axis, toModel)); + return new CameraAxes(Vector3.Transform(parameters.CameraPosition, toModel), + Axis(new Vector3(v.M11, v.M21, v.M31)), Axis(new Vector3(v.M12, v.M22, v.M32)), Axis(-new Vector3(v.M13, v.M23, v.M33)), + viewportHeight * parameters.Projection.M22 / 2, world); + } + } + + public void Draw(GraphicsDevice device, CommonShaderParameters parameters, RenderingTechnique renderingTechnique) + { + if (renderingTechnique is not (RenderingTechnique.Normal or RenderingTechnique.Emissive or RenderingTechnique.Depth)) + return; + + if (_white == null) + { + _white = new Texture2D(device, 1, 1); + _white.SetData(new[] { Color.White }); + } + + var camera = CameraAxes.From(parameters, World, device.Viewport.Height); + // Emitters are sorted back to front within one effect, not against other effects. + var emitters = _simulator.AllEmitters.Where(x => x.Particles.Count != 0) + .Select(x => (x.Settings, States: ParticleSimulator.States(x).ToList(), Frame: x.World)) + .OrderByDescending(x => Vector3.DistanceSquared(Centre(x.States), camera.Position)) + .ToList(); + + // Rigid models first: they are opaque, and write the depth the particles are drawn against. + foreach (var (settings, states, frame) in emitters.Where(x => x.Settings.Rigid.Count > 0)) + DrawRigid(device, parameters, renderingTechnique, settings, states, frame); + // The scene's depth, which soft particles fade against, holds the models alone. + if (renderingTechnique == RenderingTechnique.Depth) + return; + + var glow = renderingTechnique == RenderingTechnique.Emissive; + var blendState = device.BlendState; + // The shader's passes leave its own samplers on the device (the scene copy, the scene depth: clamped, point-filtered), and + // the models take whatever it holds, in every tab, since the device is shared: theirs drew striped. Each slot goes back. + var samplers = Enumerable.Range(0, SamplerSlots).Select(i => device.SamplerStates[i]).ToArray(); + device.SamplerStates[0] = SamplerState.LinearClamp; + foreach (var (settings, states, frame) in emitters.Where(x => x.Settings.Rigid.Count == 0)) + { + // A haze bends what is drawn behind it by now, this effect's own particles included. + var scene = !glow && settings.Distortion ? CopyScene(device) : null; + // A normal map that is not in the packs leaves the quad flat, not tilted by a white texel. + var normalMap = settings.Lit && settings.NormalMap != null ? _textureCache.Load(device, settings.NormalMap) : null; + DrawEmitter(device, _shader(), parameters, World, camera, settings, TextureFor(device, settings.Texture), states, frame, glow, scene, normalMap); + } + device.BlendState = blendState; + for (var i = 0; i < samplers.Length; i++) + device.SamplerStates[i] = samplers[i]; + } + + /// The light particles alive now, in the world: each reaches as far as the particle is wide, as CA's does, in its colour times + /// its alpha, on the models' sun scale at CA's share of it (VfxConventions.LightShare), as DrawGroundLight lights the ground with them. + public IEnumerable Lights() => _simulator.AllEmitters.Where(x => x.Settings.Light).SelectMany(e => + { + var brightness = VfxConventions.Blend(e.Settings.AdditiveMul).Brightness; + return ParticleSimulator.States(e).Select(p => new PointLight(Vector3.Transform(p.Position, World), p.Size.X, p.Colour * p.Alpha * brightness * VfxConventions.LightShare)); + }); + + public static void DrawEmitter(GraphicsDevice device, Effect effect, CommonShaderParameters parameters, Matrix world, + CameraAxes camera, EmitterSettings settings, Texture2D texture, IReadOnlyList states, Matrix? frame = null, bool glow = false, + Texture2D? scene = null, Texture2D? normalMap = null) + { + // A distortion bends what is drawn already, and adds no light to bloom. A light particle draws nothing of its own: the + // engine draws the light all of them put on the ground at once (DrawGroundLight). + if ((settings.Distortion && (glow || scene == null)) || settings.Light) + return; + // A lit particle's colour is light the sun and sky put on it, as a model's is, and blooms no more than a model's does; + // it is drawn black there, so it still hides the glow behind it by its alpha, as unlit smoke does. + var lit = settings.Lit && !glow; + if (glow && settings.Lit) + states = states.Select(p => p with { Colour = Vector3.Zero }).ToList(); + (states, var scale) = Hdr(settings, states); + var vertices = settings.Trail ? Ribbon(settings, camera, states, frame) : Quads(settings, camera, states, frame); + if (vertices.Length == 0) + return; + + effect.Parameters["WorldViewProjection"].SetValue(world * parameters.View * parameters.Projection); + effect.Parameters["WorldView"].SetValue(world * parameters.View); + effect.Parameters["World"].SetValue(world); + effect.Parameters["Lit"].SetValue(lit ? 1f : 0f); + if (lit) + { + var (toSun, sun, sky) = Sun(parameters); + var l = settings.Lighting!; + effect.Parameters["SunDirection"].SetValue(toSun); + effect.Parameters["SunColour"].SetValue(sun); + effect.Parameters["AmbientColour"].SetValue(sky); + effect.Parameters["CameraBack"].SetValue(Vector3.Normalize(new Vector3(parameters.View.M13, parameters.View.M23, parameters.View.M33))); + effect.Parameters["Scattering"].SetValue(new Vector4(l.Small, l.Large, l.Directionality, l.Emissive)); + effect.Parameters["AverageSize"].SetValue(l.AverageSize); + } + effect.Parameters["UseNormalMap"].SetValue(lit && normalMap != null ? 1f : 0f); + if (lit && normalMap != null) + effect.Parameters["NormalMap"].SetValue(normalMap); + effect.Parameters["Scale"].SetValue(scale); + effect.Parameters["Texture"].SetValue(texture); + effect.Parameters["Premultiplied"].SetValue(settings.Premultiplied ? 1f : 0f); + var (alphaWeight, brightness) = VfxConventions.Blend(settings.AdditiveMul); + effect.Parameters["Overlay"].SetValue(settings.Overlay ? 1f : 0f); + effect.Parameters["Glow"].SetValue(glow ? 1f : 0f); + effect.Parameters["BloomThreshold"].SetValue(VfxConventions.BloomThreshold); + effect.Parameters["BloomStrength"].SetValue(VfxConventions.BloomParticleStrength); + effect.Parameters["OverlayBrightness"].SetValue(brightness); + effect.Parameters["OverlayAlphaWeight"].SetValue(alphaWeight); + effect.Parameters["CrushSharp"].SetValue(settings.CrushSharp ? 1f : 0f); + effect.Parameters["UseGradient"].SetValue(settings.Gradient != null ? 1f : 0f); + effect.Parameters["GradientMapsAlpha"].SetValue(settings.GradientMapsAlpha ? 1f : 0f); + effect.Parameters["GradientRamp"].SetValue(Ramp(settings)); + effect.CurrentTechnique = effect.Techniques[settings.Distortion ? "Distortion" : "ParticleDrawing"]; + // ps_soft_edge against the solid scene's depth, when the engine drew it; a decal lies on the ground instead. + // The glow pass draws onto a target holding only the emissive models' depth, so there the scene's depth hides what lies + // behind a plain model. + var depth = parameters.SceneDepth; + var soft = depth != null && settings.SoftEdge > 0 && !settings.Decal; + var occlude = depth != null && glow; + effect.Parameters["UseSceneDepth"].SetValue(soft ? 1f : 0f); + effect.Parameters["Occlude"].SetValue(occlude ? 1f : 0f); + if (soft || occlude) + { + effect.Parameters["SceneDepth"].SetValue(depth!); + effect.Parameters["InverseDepthSize"].SetValue(new Vector2(1f / depth.Width, 1f / depth.Height)); + effect.Parameters["SoftFactor"].SetValue(settings.SoftEdge); + effect.Parameters["SoftStrength"].SetValue(settings.SoftStrength); + } + if (settings.Distortion) + { + effect.Parameters["Scene"].SetValue(scene); + effect.Parameters["InverseSceneSize"].SetValue(new Vector2(1f / scene!.Width, 1f / scene.Height)); + effect.Parameters["Reach"].SetValue(new Vector2(VfxConventions.DistortionShare)); + } + device.BlendState = BlendFor(device, settings); + foreach (var pass in effect.CurrentTechnique.Passes) + { + pass.Apply(); + device.DrawUserPrimitives(PrimitiveType.TriangleList, vertices, 0, vertices.Length / 3); + } + } + + /// The light the scene's light particles (CommonShaderParameters.PointLights, every item's Lights()) put on the + /// ground, y = 0, drawn once over all the ground they reach, under the particles: CA adds up its deferred lights before its + /// one tone map, so where several reach, the ground takes their sum. Lights at or under the ground light none of it. + public static void DrawGroundLight(GraphicsDevice device, Effect effect, CommonShaderParameters parameters) + { + var ground = VfxConventions.Decal(Vector3.Zero).Y; + var lights = parameters.PointLights.Take(CommonShaderParameters.MaxPointLights).ToList(); + var over = lights.Where(x => x.Position.Y > ground).ToList(); + if (over.Count == 0) + return; + var (left, right) = (over.Min(x => x.Position.X - x.Radius), over.Max(x => x.Position.X + x.Radius)); + var (back, front) = (over.Min(x => x.Position.Z - x.Radius), over.Max(x => x.Position.Z + x.Radius)); + VfxVertex Corner(float x, float z) => new(new Vector3(x, ground, z), Color.White, Vector2.Zero, Vector4.Zero); + VfxVertex[] vertices = [Corner(left, back), Corner(right, back), Corner(right, front), Corner(left, back), Corner(right, front), Corner(left, front)]; + + effect.CurrentTechnique = effect.Techniques["Light"]; + effect.Parameters["WorldViewProjection"].SetValue(parameters.View * parameters.Projection); + effect.Parameters["WorldView"].SetValue(parameters.View); + effect.Parameters["World"].SetValue(Matrix.Identity); + effect.Parameters["LightScale"].SetValue(VfxConventions.GroundAlbedo * SunStrength(parameters)); + CommonShaderParameters.SetPointLights(effect, lights); + device.BlendState = Straight(device)[1]; + foreach (var pass in effect.CurrentTechnique.Passes) + { + pass.Apply(); + device.DrawUserPrimitives(PrimitiveType.TriangleList, vertices, 0, 2); + } + } + + /// The sun as Shaders/Pbr light a model with it: the way to it, turned as the scene's light is; the light a face square + /// to it takes, get_sun_colour() x unchartedSunFactor over the part of the sky a 1 degree sun covers, sin^2(0.5 deg), which is + /// CA's own 0.000076 in ps_lighting; and the scale cube_ambient puts on the ambient cube. The light colour is over both. + public static (Vector3 ToSun, Vector3 Sun, Vector3 Sky) Sun(CommonShaderParameters p) + { + var turn = Matrix.CreateRotationY(p.DirLightRotationRadians_Y) * Matrix.CreateRotationX(p.DirLightRotationRadians_X); + var toSun = Vector3.Normalize(Vector3.TransformNormal(Vector3.Normalize(new Vector3(0.1f, 0.1f, 1)), turn)); + return (toSun, p.LightColour * SunStrength(p), p.LightColour * (0.261f * p.LightIntensityMult)); + } + + /// The light a face square to the sun takes, before the light colour: 20000 x 3 x the intensity x sin^2(0.5 deg). + public static float SunStrength(CommonShaderParameters p) => 20000 * 3 * p.LightIntensityMult * MathF.Pow(MathF.Sin(MathHelper.ToRadians(0.5f)), 2); + + // A vertex colour stops at 1 and the shader's Scale does not: an HDR emitter's colours are divided by the + // brightest of them and the draw multiplied back, so a colour of 4 lights a quarter-grey texel fully. Past 255 even + // the faintest texel a byte holds saturates. The glow pass takes the whole colour; the shader weighs what it draws. + static (IReadOnlyList, float) Hdr(EmitterSettings s, IReadOnlyList states) + { + var brightness = VfxConventions.Blend(s.AdditiveMul).Brightness; + var top = states.Select(p => Math.Max(p.Colour.X, Math.Max(p.Colour.Y, p.Colour.Z)) * brightness).DefaultIfEmpty(1).Max(); + var scale = Math.Clamp(top, 1, 255); + return scale > 1 ? (states.Select(p => p with { Colour = p.Colour / scale }).ToList(), scale) : (states, 1); + } + + // ps_gradient's strip at the 16 points the shader interpolates between: rgb off curve_colour, alpha off curve_alpha. + static Vector4[] Ramp(EmitterSettings s) => Enumerable.Range(0, 16).Select(i => i / 15f).Select(t => s.Gradient == null ? Vector4.One + : new Vector4(s.Gradient.Evaluate(0, t, 0, 1), s.Gradient.Evaluate(1, t, 0, 1), s.Gradient.Evaluate(2, t, 0, 1), s.GradientAlpha?.Evaluate(0, t, 0, 1) ?? 1)).ToArray(); + + public static VfxVertex[] Quads(EmitterSettings s, CameraAxes camera, IReadOnlyList states, Matrix? frame = null) + { + var vertices = new VfxVertex[states.Count * 6]; + var n = 0; + var fade = EmitterFade(s, camera, frame); + foreach (var state in states.OrderByDescending(x => Vector3.DistanceSquared(x.Position, camera.Position))) + n = AddQuad(vertices, n, s, camera, state, frame ?? Matrix.Identity, fade); + return vertices; + } + + /// enable_trail_mode: the particles, oldest first, joined into one camera-facing strip as wide as each. The + /// newest particle's texture cell runs along it, its top (a velocity-aligned quad's head) at the newest, mirrored and turned + /// as that particle's quad would be; each point grows to the fewest pixels across and fades into the ground as a quad does. + public static VfxVertex[] Ribbon(EmitterSettings s, CameraAxes camera, IReadOnlyList states, Matrix? frame = null) + { + if (states.Count < 2) + return []; + var newest = states[^1]; + var uv = UvRect(s, newest.Cell); + var fade = EmitterFade(s, camera, frame); + var toWorld = camera.ToWorld ?? Matrix.Identity; + var eye = Vector3.Transform(camera.Position, toWorld); + var last = states.Count - 1; + var edges = new (Vector3 Left, Vector3 Right, Vector3 Colour, float Alpha, float T, float Crush, float Width)[states.Count]; + for (var i = 0; i <= last; i++) + { + var p = states[i]; + var along = states[Math.Min(i + 1, last)].Position - states[Math.Max(i - 1, 0)].Position; + var side = Vector3.Cross(along, camera.Position - p.Position); + var width = p.Size.X * VfxConventions.MinPixelGrowth(p.Size.X, Vector3.Distance(camera.Position, p.Position), camera.PixelScale, s.MinPixels); + side = side.LengthSquared() > 1e-12f ? Vector3.Normalize(side) * (width / 2) : Vector3.Zero; + edges[i] = (p.Position - side, p.Position + side, p.Colour, Faded(s, camera, p, fade), 1 - i / (float)last, p.Crush, width); + } + + var (next, blend) = (UvRect(s, newest.NextCell), newest.CellBlend); + // across is the quad's x; the head, at the newest, is its top. + VfxVertex Vertex(Vector3 position, Vector3 colour, float alpha, float across, float t, float crush, float width) + { + var (u, v) = VfxConventions.QuadUv(across, 1 - t, newest.FlipU, newest.FlipV, s.UvTurn); + if (s.SoftEdge > 0 && !s.Decal) + alpha *= VfxConventions.SoftEdge(Vector3.Transform(position, toWorld), eye, s.SoftEdge, s.SoftStrength, width); + return new(position, VertexColour(s, colour, alpha), new Vector2(uv.X + u * uv.Z, uv.Y + v * uv.W), new Vector4(next.X + u * next.Z, next.Y + v * next.W, blend, crush), new Vector2(width)); + } + var vertices = new VfxVertex[last * 6]; + var n = 0; + for (var i = 0; i < last; i++) + { + var (a, b) = (edges[i], edges[i + 1]); + vertices[n++] = Vertex(a.Left, a.Colour, a.Alpha, 0, a.T, a.Crush, a.Width); + vertices[n++] = Vertex(a.Right, a.Colour, a.Alpha, 1, a.T, a.Crush, a.Width); + vertices[n++] = Vertex(b.Right, b.Colour, b.Alpha, 1, b.T, b.Crush, b.Width); + vertices[n++] = Vertex(a.Left, a.Colour, a.Alpha, 0, a.T, a.Crush, a.Width); + vertices[n++] = Vertex(b.Right, b.Colour, b.Alpha, 1, b.T, b.Crush, b.Width); + vertices[n++] = Vertex(b.Left, b.Colour, b.Alpha, 0, b.T, b.Crush, b.Width); + } + return vertices; + } + + // CullDistances: the whole emitter's fade, by the distance of its origin from the camera. + static float EmitterFade(EmitterSettings s, CameraAxes camera, Matrix? frame) => + VfxConventions.CullFade(Vector3.Distance(camera.Position, (frame ?? Matrix.Identity).Translation), s.EmitterCull); + + // A particle's alpha, faded with the emitter's and by its own distance from the camera (ParticleCullDistances). + static float Faded(EmitterSettings s, CameraAxes camera, ParticleState p, float emitterFade) => + p.Alpha * emitterFade * VfxConventions.CullFade(Vector3.Distance(camera.Position, p.Position), s.ParticleCull); + + static int AddQuad(VfxVertex[] vertices, int n, EmitterSettings s, CameraAxes camera, ParticleState p, Matrix frame, float fade) + { + var toWorld = camera.ToWorld ?? Matrix.Identity; + if (s.Decal) + p = p with { Position = Vector3.Transform(VfxConventions.Decal(Vector3.Transform(p.Position, toWorld)), Matrix.Invert(toWorld)) }; + var (across, up, height) = Axes(s, camera, p, frame, toWorld); + var width = p.Size.X; + var grow = VfxConventions.MinPixelGrowth(Math.Min(width, height), Vector3.Distance(camera.Position, p.Position), camera.PixelScale, s.MinPixels); + (width, height) = (width * grow, height * grow); + var alpha = Faded(s, camera, p, fade); + var colour = VertexColour(s, p.Colour, alpha); + var (uv, next) = (UvRect(s, p.Cell), UvRect(s, p.NextCell)); + var eye = Vector3.Transform(camera.Position, toWorld); + VfxVertex Corner(float x, float y) + { + var (u, v) = VfxConventions.QuadUv(x, y, p.FlipU, p.FlipV, s.UvTurn); + var position = p.Position + across * ((x - s.Pivot.X) * width) + up * ((y - s.Pivot.Y) * height); + var soft = s.SoftEdge > 0 && !s.Decal ? VertexColour(s, p.Colour, alpha * VfxConventions.SoftEdge(Vector3.Transform(position, toWorld), eye, s.SoftEdge, s.SoftStrength, Math.Min(width, height))) : colour; + return new(position, soft, new Vector2(uv.X + u * uv.Z, uv.Y + v * uv.W), new Vector4(next.X + u * next.Z, next.Y + v * next.W, p.CellBlend, p.Crush), new Vector2(width, height)); + } + + vertices[n++] = Corner(0, 0); + vertices[n++] = Corner(1, 0); + vertices[n++] = Corner(1, 1); + vertices[n++] = Corner(0, 0); + vertices[n++] = Corner(1, 1); + vertices[n++] = Corner(0, 1); + return n; + } + + // The quad's two edge directions and its height, per orientation_mode. frame: the emitter's matrix, whose axes the + // two local-Y modes use. The three rare modes (29 vanilla chains) are readings of their names, not measurements. + // toWorld: the effect's matrix, whose world a decal lies flat in. + static (Vector3 Across, Vector3 Up, float Height) Axes(EmitterSettings s, CameraAxes camera, ParticleState p, Matrix frame, Matrix toWorld) + { + var (sin, cos) = MathF.SinCos(p.Angle); + // vs_translation's restriction, as CA's gs_translation builds the quad: Flat lies in the world's XZ plane, as a decal + // does; Upright stands on the world's Y and spreads along the camera's right; Object3D turns by the particle's X, Y + // and Z and then its emitter, as a rigid particle turns. + if (s.Decal || s.Orientation is QuadOrientation.Flat or QuadOrientation.Upright) + { + var toModel = Matrix.Invert(toWorld); + Vector3 World(Vector3 axis) => Vector3.Normalize(Vector3.TransformNormal(axis, toModel)); + if (s.Decal || s.Orientation == QuadOrientation.Flat) + return (World(new Vector3(cos, 0, -sin)), World(new Vector3(sin, 0, cos)), p.Size.Y); + var worldUp = World(Vector3.Up); + return (camera.Right * cos + worldUp * sin, worldUp * cos - camera.Right * sin, p.Size.Y); + } + if (s.Orientation == QuadOrientation.Object3D) + { + var turn = Matrix.CreateRotationX(p.TurnXy.X) * Matrix.CreateRotationY(p.TurnXy.Y) * Matrix.CreateRotationZ(p.Angle) + * VfxConventions.RigidFrame(frame, s.RigidOnTerrain); + return (Vector3.TransformNormal(Vector3.UnitX, turn), Vector3.TransformNormal(Vector3.UnitY, turn), p.Size.Y); + } + if (s.Orientation == QuadOrientation.Horizontal) + return (new Vector3(cos, 0, -sin), new Vector3(sin, 0, cos), p.Size.Y); + + // Flat in the emitter's XZ plane, or upright on its Y, turned by the particle's angle about that Y. + if (s.Orientation is QuadOrientation.LocalHorizontal or QuadOrientation.LocalVertical) + { + var (x, z) = (Vector3.Normalize(frame.Right), Vector3.Normalize(frame.Backward)); + var across = x * cos - z * sin; + return (across, s.Orientation == QuadOrientation.LocalHorizontal ? x * sin + z * cos : Vector3.Normalize(frame.Up), p.Size.Y); + } + + // Upright, turning about the world's Y to face the camera. + if (s.Orientation == QuadOrientation.Vertical) + { + var side = Vector3.Cross(Vector3.Up, camera.Position - p.Position); + if (side.LengthSquared() > 1e-8f) + return (Vector3.Normalize(side), Vector3.Up, p.Size.Y); + } + + // As CA's vs_translation_velocity_v2 builds them, from the particle's own line to the camera, not the view's axis. + if (s.Orientation is QuadOrientation.VelocityAligned or QuadOrientation.VelocityAxis && p.Velocity.LengthSquared() > 1e-8f) + { + var look = Vector3.Normalize(p.Velocity); + var toCamera = camera.Position - p.Position; + var stretch = VfxConventions.Stretch(p.Velocity.Length(), p.Stretch, s.StretchCap); + if (toCamera.LengthSquared() > 1e-8f) + { + toCamera.Normalize(); + var side = Vector3.Cross(toCamera, look); + // Mode 2: along the velocity itself, so the view foreshortens it. + if (s.Orientation == QuadOrientation.VelocityAxis) + { + if (side.LengthSquared() > 1e-8f) + return (Vector3.Normalize(side), look, p.Size.Y * stretch); + } + else + { + // Mode 5: facing the camera, along the velocity's line across the screen, stretched by as much of the + // velocity as crosses it and never under its own size. Seen down its path the side turns to the screen's. + var across = Vector3.Lerp(camera.Right, side.LengthSquared() > 1e-12f ? Vector3.Normalize(side) : camera.Right, Math.Clamp(side.Length() * 100, 0, 1)); + across.Normalize(); + var up = Vector3.Normalize(Vector3.Cross(across, toCamera)); + return (across, up, p.Size.Y * Math.Max(stretch * Vector3.Dot(look, up), 1)); + } + } + } + + return (camera.Right * cos + camera.Up * sin, camera.Up * cos - camera.Right * sin, p.Size.Y); + } + + /// A particle vertex for Shaders/Particle. Extra: the next flipbook cell's texture coordinate (xy), how far to + /// blend into it (z), and the particle's alpha crush level (w). Size: its width and height, metres. + public struct VfxVertex(Vector3 position, Color color, Vector2 textureCoordinate, Vector4 extra, Vector2 size = default) : IVertexType + { + public Vector3 Position = position; + public Color Color = color; + public Vector2 TextureCoordinate = textureCoordinate; + public Vector4 Extra = extra; + public Vector2 Size = size; + + public static readonly VertexDeclaration Declaration = new( + new VertexElement(0, VertexElementFormat.Vector3, VertexElementUsage.Position, 0), + new VertexElement(12, VertexElementFormat.Color, VertexElementUsage.Color, 0), + new VertexElement(16, VertexElementFormat.Vector2, VertexElementUsage.TextureCoordinate, 0), + new VertexElement(24, VertexElementFormat.Vector4, VertexElementUsage.TextureCoordinate, 1), + new VertexElement(40, VertexElementFormat.Vector2, VertexElementUsage.TextureCoordinate, 2)); + + readonly VertexDeclaration IVertexType.VertexDeclaration => Declaration; + } + + /// The texture rectangle (x, y, width, height) of a cell, inside the atlas cell when the texture is an atlas. + public static Vector4 UvRect(EmitterSettings s, int cell) + { + var (ax, ay) = (Math.Max(1, s.AtlasX), Math.Max(1, s.AtlasY)); + var (cx, cy) = (Math.Max(1, s.CellsX), Math.Max(1, s.CellsY)); + var atlas = Math.Clamp(s.AtlasCell, 0, ax * ay - 1); + cell = Math.Clamp(cell, 0, cx * cy - 1); + var (w, h) = (1f / ax / cx, 1f / ay / cy); + return new Vector4((atlas % ax) / (float)ax + (cell % cx) * w, (atlas / ax) / (float)ay + (cell / cx) * h, w, h); + } + + /// + /// Premultiplied chains: (rgb x a, a x alphaWeight) under one premultiplied blend, so alpha and additive + /// particles mix in one sorted draw. The 825 straight-alpha chains draw their plain colour under a + /// straight-alpha or additive blend instead. A lit chain's colour comes straight, since the shader lights it first, and so + /// does an overlay chain's, since the shader overlays it on the texture before the brightness and alpha weight go on. + /// + public static Color VertexColour(EmitterSettings s, Vector3 rgb, float alpha) + { + var (alphaWeight, brightness) = VfxConventions.Blend(s.AdditiveMul); + alpha = Math.Clamp(alpha, 0, 1); + return s.Premultiplied && !s.Lit && !s.Overlay + ? new Color(new Vector4(rgb * alpha * brightness, alpha * alphaWeight)) + : new Color(new Vector4(s.Overlay ? rgb : rgb * brightness, alpha)); + } + + // The app draws its scene onto nothing and lays it over the background by its alpha, so a particle's alpha is what it + // hides: nothing for light it adds, its own alpha for one laid over. MonoGame's Additive and NonPremultiplied write that + // alpha times itself, which blacked the background out under a light pool's unlit corners. + // A straight-alpha chain with a mixed additive_blend_mul is rounded to alpha or additive; + // premultiply its texture on load if one turns up that needs the mix. + // A haze writes the scene back moved, alpha and all, as CA's screen pass moves each pixel; laid over, it left a second copy. + public static BlendState BlendFor(GraphicsDevice device, EmitterSettings s) => + s.Distortion ? BlendState.Opaque : s.Premultiplied ? BlendState.AlphaBlend : Straight(device)[s.AdditiveMul >= 0.5f ? 1 : 0]; + + // MonoGame's DirectX device has 16 sampler slots (GraphicsCapabilities.MaxTextureSlots). + const int SamplerSlots = 16; + + // Laid over, added, and depth only. One set per device: MonoGame binds a state to the first device it is set on. + static readonly ConditionalWeakTable s_straight = new(); + static BlendState[] Straight(GraphicsDevice device) => s_straight.GetValue(device, _ => + [ + new BlendState { ColorSourceBlend = Blend.SourceAlpha, ColorDestinationBlend = Blend.InverseSourceAlpha, AlphaSourceBlend = Blend.One, AlphaDestinationBlend = Blend.InverseSourceAlpha }, + new BlendState { ColorSourceBlend = Blend.SourceAlpha, ColorDestinationBlend = Blend.One, AlphaSourceBlend = Blend.Zero, AlphaDestinationBlend = Blend.One }, + new BlendState { ColorWriteChannels = ColorWriteChannels.None }, + ]); + + /// Loads a chain's texture from the loaded packs. Missing or undecodable: null (drawn white) and one log line. + public static Func PackTextureLoader(IPackFileService packFileService, Action log) => (device, path) => + { + try + { + var texture = ImageLoader.LoadTextureAsTexture2d(path, packFileService, device, out _, false); + if (texture == null) + log($"vfx texture {path} is not in any loaded pack, so it is drawn white"); + return texture; + } + catch (Exception e) + { + log($"vfx texture {path} did not load ({e.Message}), so it is drawn white"); + return null; + } + }; + + // continuous_rigid: each particle draws its model's LOD 0 through the model's own materials, opaque. The chain's colour + // and alpha are not applied to it. + void DrawRigid(GraphicsDevice device, CommonShaderParameters parameters, RenderingTechnique technique, EmitterSettings s, IReadOnlyList states, Matrix frame) + { + if (_meshes == null) + return; + // The engine draws effects reading depth without writing it; a model writes its own, or its far side draws over its near one. + var (blendState, depthState) = (device.BlendState, device.DepthStencilState); + device.BlendState = BlendState.Opaque; + device.DepthStencilState = DepthStencilState.Default; + var turn = VfxConventions.RigidFrame(frame, s.RigidOnTerrain); + foreach (var p in states) + { + var entry = s.Rigid[Math.Clamp(p.Model, 0, s.Rigid.Count - 1)]; + var model = _meshes.Get(entry.ModelFile); + _meshes.Pose(model, entry.AnimFile, p.Age, entry.AnimSpeed); + var matrix = VfxConventions.RigidMatrix(model.Box, entry.Pivot, p.Size.X, new Vector3(p.TurnXy, p.Angle), turn, p.Position) * World; + var tinted = parameters with { ModelColour = new Vector4(p.Colour, p.Alpha) }; + // A see-through model shows its nearest faces once: its depth first, then its colour laid over where it is nearest. + (BlendState, DepthStencilState)[] passes = p.Alpha < 1 && technique != RenderingTechnique.Depth + ? [(Straight(device)[2], DepthStencilState.Default), (Straight(device)[0], DepthStencilState.DepthRead)] + : [(BlendState.Opaque, DepthStencilState.Default)]; + foreach (var (blend, depth) in passes) + { + (device.BlendState, device.DepthStencilState) = (blend, depth); + foreach (var (geometry, material, local) in model.Meshes) + new GeometryRenderItem(geometry, material, local * matrix).Draw(device, tinted, technique); + } + } + device.BlendState = blendState; + device.DepthStencilState = depthState; + } + + // DISTORTION samples what is drawn already, so the bound target is copied first, once a frame. Null on the back buffer. + Texture2D? CopyScene(GraphicsDevice device) + { + if (device.GetRenderTargets() is not [{ RenderTarget: RenderTarget2D target }, ..]) + return null; + if (_scene == null || _scene.Width != target.Width || _scene.Height != target.Height) + { + _scene?.Dispose(); + _scene = new RenderTarget2D(device, target.Width, target.Height, false, SurfaceFormat.Color, DepthFormat.None); + } + _batch ??= new SpriteBatch(device); + var (viewport, depth, raster, blend, sampler) = (device.Viewport, device.DepthStencilState, device.RasterizerState, device.BlendState, device.SamplerStates[0]); + device.SetRenderTarget(_scene); + _batch.Begin(SpriteSortMode.Immediate, BlendState.Opaque, SamplerState.PointClamp, DepthStencilState.None, RasterizerState.CullNone); + _batch.Draw(target, Vector2.Zero, Color.White); + _batch.End(); + device.SetRenderTarget(target); + (device.Viewport, device.DepthStencilState, device.RasterizerState, device.BlendState, device.SamplerStates[0]) = (viewport, depth, raster, blend, sampler); + return _scene; + } + + Texture2D TextureFor(GraphicsDevice device, string? path) => + string.IsNullOrEmpty(path) ? _white! : _textureCache.Load(device, path) ?? _white!; + + static Vector3 Centre(List states) => states.Aggregate(Vector3.Zero, (sum, x) => sum + x.Position) / states.Count; + + // The textures belong to the VfxTextureCache, which outlives this item, and the shader to whoever supplied it. + public void Dispose() + { + // Nulled, so a frame drawn after a dispose builds them again rather than drawing with disposed ones. + _white?.Dispose(); + _white = null; + _scene?.Dispose(); + _scene = null; + _batch?.Dispose(); + _batch = null; + IsDisposed = true; + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/ParticleSimulator.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/ParticleSimulator.cs new file mode 100644 index 000000000..51545356c --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/ParticleSimulator.cs @@ -0,0 +1,575 @@ +using Microsoft.Xna.Framework; +using Shared.GameFormats.Vfx; + +namespace GameWorld.Core.Vfx +{ + /// A live particle, in its emitter's simulation space (see Spawn and States). + public struct Particle + { + public Vector3 Position; + public Vector3 Velocity; + public float Age; + public float Life; + public float Angle; + public float Spin; + public Vector2 Size; + public float Alpha; + public Vector4 Tint; + // vs_cell_texture's random cell and vs_animated_texture's random row, from one draw, as CA's take one random_unary. + public float RCell; + // One draw per over-life property, taken at spawn. + public float RScale, RDeceleration, RSpinDeceleration, RForce, RAlpha, RColour, RStretch; + // Drawn only when the emitter collides, so an emitter that does not keeps its random sequence. + public float Bounce, Damping; + public bool FlipU, FlipV; + // Drawn only for a rigid emitter: its model, and its turn and spin about X and Y. + public int Model; + public Vector2 TurnXy, SpinXy; + // Its number in its emitter, in spawn order: what a particle event's effect follows it by. + public int Id; + } + + /// A particle as drawn now, in the effect's model space. + public readonly record struct ParticleState(Vector3 Position, Vector3 Velocity, Vector2 Size, float Angle, Vector3 Colour, float Alpha, float Stretch, int Cell, bool FlipU = false, bool FlipV = false, int NextCell = 0, float CellBlend = 0, float Crush = 0, Vector2 TurnXy = default, int Model = 0, float Age = 0); + + public class EmitterRuntime + { + public EmitterRuntime(EmitterSettings settings, Random random) + { + Settings = settings; + Random = random; + } + + public EmitterSettings Settings { get; } + public List Particles { get; } = []; + /// The emitter's transform at the last simulated time. + public Matrix World { get; internal set; } = Matrix.Identity; + + internal Random Random { get; } + internal float Start, Life, RateScale, BurstInterval, BurstLength, RRate, RTransform, Accumulator; + internal bool CapLogged; + // SpawnSpacing: where the emitter was at the last step, and the metres moved since the last particle. + internal Vector3 LastPosition; + internal float Travelled; + + /// A particle event's emitter: its particle's colour and alpha (track_colour, track_alpha). White otherwise. + public Vector4 Tint { get; internal set; } = Vector4.One; + // Particle events: the ids given out and the first not yet started, the particles that died this step, and the event + // effects running. + internal int NextId, Started; + internal readonly List Dead = []; + internal readonly List Children = []; + internal bool ChildCapLogged; + } + + /// One particle's copy of a particle event's effect: a simulation of its own, placed at Frame in the parent's space. + sealed class ChildEffect(ParticleEvent particleEvent, int parent, float born, Matrix frame) + { + public ParticleEvent Event { get; } = particleEvent; + /// The particle's id; -1 once it has died, and for a death event. + public int Parent { get; set; } = parent; + /// The parent's time at which the child's clock reads 0. + public float Born { get; } = born; + public Matrix Frame { get; set; } = frame; + public ParticleSimulator Simulator { get; set; } = null!; + } + + /// + /// One effect's particles. Deterministic: fixed 1/60 s steps, and a seed per emitter. Its clock is the + /// effect's own (main animation time minus the tag's Start Time). Going back re-simulates from the + /// start, so a scrub lands where a straight run would. + /// + public class ParticleSimulator + { + public const float Step = 1f / 60; + public const int ParticleCap = 2000; + const float Epsilon = 1e-5f; + + private readonly List _settings; + private readonly int _seed; + private readonly Func _effectWorldAt; + private float _spawnEnd; + private readonly Action? _log; + private readonly List _emitters = []; + private float _origin; + private int _steps; + + public IReadOnlyList Emitters => _emitters; + + /// A particle event's effects running on one emitter at most; one per particle is a whole simulation. + public const int ChildCap = 64; + + /// The emitters and, after them, every particle event effect's emitters, theirs included. + public IEnumerable AllEmitters => _emitters.Concat(_emitters.SelectMany(e => e.Children.SelectMany(c => c.Simulator.AllEmitters))); + + /// Nothing alive and nothing more to come: every emitter past its life or its spawn end, with no events running. + internal bool Spent => _emitters.All(e => e.Particles.Count == 0 && e.Children.Count == 0 + && (Time > _spawnEnd || !e.Settings.InfiniteLife && (Time - e.Start) * e.Settings.SpeedMultiplier > e.Life)); + + /// Stops spawning at an effect time (track_lifetime: the particle a child effect follows has died). + internal void EndSpawning(float time) => _spawnEnd = Math.Min(_spawnEnd, time); + + /// Effect time simulated so far; below 0 while an emitter pre-warms. + public float Time => _origin + _steps * Step; + + /// the effect's frame at an effect time: the bone, then the tag's offset + /// effect time at which spawning stops; live particles finish their lives + public ParticleSimulator(IEnumerable settings, int seed, Func effectWorldAt, float spawnEnd = float.PositiveInfinity, Action? log = null) + { + _settings = settings.Where(x => x.Draws).ToList(); + _seed = seed; + _effectWorldAt = effectWorldAt; + _spawnEnd = spawnEnd; + _log = log; + Reset(); + } + + /// Simulates up to an effect time. Before 0 the effect has not started, so nothing is alive. + public void AdvanceTo(float effectTime) + { + if (effectTime < 0) + { + if (_steps != 0) + Reset(); + return; + } + + // Whole steps, counted in double: minutes in, a float time cannot hold Epsilon, and a paused clock asked for the + // same time again started over from nothing every frame. + var steps = (int)Math.Floor(((double)effectTime + Epsilon - _origin) / Step); + if (steps < _steps) + Reset(); + while (_steps < steps) + StepOnce(); + + var effectWorld = _effectWorldAt(Time); + foreach (var e in _emitters) + e.World = EmitterWorld(e, Time, effectWorld); + } + + void Reset() + { + _emitters.Clear(); + for (var i = 0; i < _settings.Count; i++) + { + var s = _settings[i]; + var random = new Random(_seed + i * 7919); + float Draw((float Min, float Max) range) => MathHelper.Lerp(range.Min, range.Max, (float)random.NextDouble()); + var e = new EmitterRuntime(s, random) + { + Start = Draw(s.StartTime) + s.StartOffset, + Life = Draw(s.LifeTime) + Draw(s.EmitterLifeBias), + RateScale = Draw(s.RateScale), + RRate = (float)random.NextDouble(), + RTransform = (float)random.NextDouble(), + }; + if (s.BurstInterval is { } burst) + { + e.BurstInterval = Draw(burst); + e.BurstLength = Draw(s.BurstLength); + } + _emitters.Add(e); + } + + // A negative Start Time pre-warms: that emitter was already running when the effect began. + _origin = Math.Max(-VfxConventions.MaxPreWarm, _emitters.Select(x => x.Start).Append(0).Min()); + _steps = 0; + var effectWorld = _effectWorldAt(_origin); + foreach (var e in _emitters) + e.LastPosition = EmitterWorld(e, _origin, effectWorld).Translation; + } + + void StepOnce() + { + var end = Time + Step; + var effectWorld = _effectWorldAt(end); + foreach (var e in _emitters) + { + var s = e.Settings; + var dt = Step * s.SpeedMultiplier; + var world = EmitterWorld(e, end, effectWorld); + var age = (end - e.Start) * s.SpeedMultiplier; + Move(e, dt, world, age); + + var alive = age > 0 && (s.InfiniteLife || age <= e.Life); + var from = e.LastPosition; + e.LastPosition = world.Translation; + if (!alive || end > _spawnEnd + Epsilon || !VfxConventions.InBurst(age, e.BurstInterval, e.BurstLength)) + continue; + + var limit = Math.Min(s.MaxParticles, ParticleCap); + var moving = dt > 0 ? (world.Translation - from) / dt : Vector3.Zero; + var carried = s.Space == SimulationSpace.World ? VfxConventions.Inherited(moving, s.InheritedSpeed) : Vector3.Zero; + if (s.SpawnSpacing > 0) + SpawnAlong(e, age, world, from, dt, limit, carried, moving); + + var rate = s.Rate?.Evaluate(0, VfxConventions.EmitterTime(age, e.Life, s.LoopRate), e.RRate) ?? 0; + e.Accumulator += Math.Max(0, rate) * e.RateScale * dt; + if (e.Accumulator < 1) + continue; + + // The step's whole particles are taken in one go: "-= 1" in a loop stops changing a float past + // 2^25, which a modder's rate reaches, and the loop never ended. At most `limit` tries a step. + var due = MathF.Floor(e.Accumulator); + e.Accumulator -= due; + var tries = (int)Math.Min(due, limit); + var tried = 0; + for (; tried < tries && e.Particles.Count < limit; tried++) + Spawn(e, age, world, carried, moving); + LogCap(e, due, tried, limit); + } + foreach (var e in _emitters.Where(x => x.Settings.Events.Count > 0)) + StepEvents(e, end, EmitterWorld(e, end, effectWorld)); + _steps++; + } + + // Particle events: an effect started for each particle born or died this step, then each running one moved to its particle + // and run to now. One whose particle has gone and that has nothing left to draw is dropped. + void StepEvents(EmitterRuntime e, float end, Matrix world) + { + var events = e.Settings.Events; + foreach (var p in e.Particles.Where(x => x.Id >= e.Started)) + foreach (var spawn in events.Where(x => !x.OnDeath)) + Start(e, spawn, p.Id, end - p.Age, State(e, p, world)); + e.Started = e.NextId; + foreach (var p in e.Dead) + { + foreach (var child in e.Children.Where(x => x.Parent == p.Id)) + { + child.Parent = -1; + if (child.Event.TrackLifetime) + child.Simulator.EndSpawning(end - child.Born); + } + foreach (var death in events.Where(x => x.OnDeath)) + Start(e, death, -1, end, State(e, p, world)); + } + e.Dead.Clear(); + + foreach (var child in e.Children) + { + var ev = child.Event; + if (child.Parent >= 0 && (ev.TrackPosition || ev.TrackColour || ev.TrackAlpha) && Find(e.Particles, child.Parent) is { } p) + { + var state = State(e, p, world); + if (ev.TrackPosition) + child.Frame = Frame(ev, state); + Tint(child, state); + } + child.Simulator.AdvanceTo(end - child.Born); + } + e.Children.RemoveAll(x => x.Parent < 0 && x.Simulator.Spent); + } + + void Start(EmitterRuntime e, ParticleEvent particleEvent, int parent, float at, ParticleState p) + { + if (e.Children.Count >= ChildCap) + { + if (!e.ChildCapLogged) + _log?.Invoke($"emitter {e.Settings.Name}: {ChildCap} particle event effects running, the rest are not started"); + e.ChildCapLogged = true; + return; + } + var child = new ChildEffect(particleEvent, parent, at + particleEvent.Offset, Frame(particleEvent, p)); + child.Simulator = new ParticleSimulator(particleEvent.Emitters, e.Random.Next(), _ => child.Frame, log: _log); + Tint(child, p); + e.Children.Add(child); + } + + // Where a child effect sits in its parent's space: at its particle, scaled by its size (track_scale), turned by its + // heading or its own turn (track_orientation_mode). + static Matrix Frame(ParticleEvent particleEvent, ParticleState p) + { + var turn = particleEvent.Turn switch + { + VfxEventTurn.Velocity when p.Velocity.LengthSquared() > 1e-12f => VfxConventions.UpTo(Vector3.Normalize(p.Velocity)), + VfxEventTurn.Particle => Matrix.CreateRotationX(p.TurnXy.X) * Matrix.CreateRotationY(p.TurnXy.Y) * Matrix.CreateRotationZ(p.Angle), + _ => Matrix.Identity, + }; + return (particleEvent.TrackScale ? Matrix.CreateScale(p.Size.X) : Matrix.Identity) * turn * Matrix.CreateTranslation(p.Position); + } + + static void Tint(ChildEffect child, ParticleState p) + { + var tint = new Vector4(child.Event.TrackColour ? p.Colour : Vector3.One, child.Event.TrackAlpha ? p.Alpha : 1); + foreach (var e in child.Simulator.Emitters) + e.Tint = tint; + } + + // Particles stay in spawn order, so in id order. + static Particle? Find(List particles, int id) + { + var (low, high) = (0, particles.Count - 1); + while (low <= high) + { + var middle = (low + high) / 2; + var at = particles[middle].Id; + if (at == id) + return particles[middle]; + (low, high) = at < id ? (middle + 1, high) : (low, middle - 1); + } + return null; + } + + void LogCap(EmitterRuntime e, float due, int spawned, int limit) + { + if (due <= spawned || e.Particles.Count < limit || limit != ParticleCap || e.CapLogged) + return; + e.CapLogged = true; + _log?.Invoke($"emitter {e.Settings.Name}: {ParticleCap} particles reached, the rest are not spawned"); + } + + // SpawnSpacing: one particle per that many metres the emitter moved this step, laid back along the step's path + // and aged and moved by the part of the step since it passed there, so a fast emitter leaves them evenly spaced. + void SpawnAlong(EmitterRuntime e, float age, Matrix world, Vector3 from, float dt, int limit, Vector3 carried, Vector3 moving) + { + var spacing = e.Settings.SpawnSpacing; + var to = world.Translation; + var travelled = Vector3.Distance(from, to); + e.Travelled += travelled; + var due = MathF.Floor(e.Travelled / spacing); + if (due < 1) + return; + e.Travelled -= due * spacing; + // Oldest first, so the particle list stays in spawn order, the order a trail joins them in. + var spawned = 0; + for (var k = (int)Math.Min(due, limit) - 1; k >= 0 && e.Particles.Count < limit; k--, spawned++) + { + var back = travelled > 0 ? Math.Clamp((e.Travelled + k * spacing) / travelled, 0, 1) : 0; + var at = world; + at.Translation = Vector3.Lerp(to, from, back); + var count = e.Particles.Count; + Spawn(e, age, at, carried, moving); + if (e.Particles.Count <= count) + continue; + var p = e.Particles[^1]; + p.Age = back * dt; + if (!e.Settings.SkipPositionUpdate) + p.Position += p.Velocity * p.Age; + e.Particles[^1] = p; + } + LogCap(e, due, spawned, limit); + } + + static Matrix EmitterWorld(EmitterRuntime e, float time, Matrix effectWorld) + { + var s = e.Settings; + var age = (time - e.Start) * s.SpeedMultiplier; + var position = Vector(s.EmitterPosition, VfxConventions.EmitterTime(age, e.Life, s.LoopPosition), e.RTransform); + var orientation = Vector(s.EmitterOrientation, VfxConventions.EmitterTime(age, e.Life, s.LoopOrientation), e.RTransform); + return VfxConventions.Rotation(orientation) * Matrix.CreateTranslation(position) * s.ReferenceTransform * effectWorld; + } + + // carried: the share of the emitter's own velocity a world-space particle starts with (SpawnInheritedSpeed). moving: that + // velocity, in the effect's space. + static void Spawn(EmitterRuntime e, float age, Matrix world, Vector3 carried, Vector3 moving) + { + var s = e.Settings; + var random = e.Random; + float Next() => (float)random.NextDouble(); + float Draw((float Min, float Max) range) => MathHelper.Lerp(range.Min, range.Max, Next()); + + var uInitial = VfxConventions.EmitterTime(age, e.Life, s.LoopInitial); + var life = VfxConventions.Biased(s.ParticleLife?.Evaluate(0, VfxConventions.EmitterTime(age, e.Life, s.LoopLife), Next(), 1) ?? 1, Draw(s.LifeBias)); + if (!(life > 0)) // NaN too (a malformed lifetime bias): it would never die + return; + + // Where, in the emitter's space: the box, then the ring. + var uArea = VfxConventions.EmitterTime(age, e.Life, s.LoopArea); + var offset = Vector3.Zero; + if (s.SpawnBox != null) + offset += VfxConventions.BoxOffset(Vector(s.SpawnBox, uArea, Next()), new Vector3(Next(), Next(), Next())); + if (s.SpawnRing != null) + { + var ring = Next(); + var radius = MathHelper.Lerp(s.SpawnRing.Evaluate(0, uArea, ring), s.SpawnRing.Evaluate(1, uArea, ring), Next()); + var (from, to) = VfxConventions.RingArc(s.SpawnArc); + var angle = MathHelper.ToRadians(MathHelper.Lerp(from, to, Next())); + offset += new Vector3(MathF.Cos(angle), 0, MathF.Sin(angle)) * radius; + } + + // Which way, and how fast. + var direction = VfxConventions.Direction(MathHelper.Lerp(s.SpawnAngle.X, s.SpawnAngle.Y, Next()), Draw(s.Spread)); + // On the ground, keeping the emitter's heading: turned into the emitter's space here, as the world turns it back below. + if (s.DirectionOnTerrain) + direction = Vector3.TransformNormal(direction, VfxConventions.RigidFrame(world, true) * Matrix.Invert(VfxConventions.RigidFrame(world, false))); + if (s.InheritedDirectionBlend > 0 && moving != Vector3.Zero) + direction = VfxConventions.InheritDirection(direction, Vector3.TransformNormal(moving, Matrix.Invert(world)), s.InheritedDirectionBlend); + var velocity = direction * (s.InitialSpeed?.Evaluate(0, VfxConventions.EmitterTime(age, e.Life, s.LoopSpeed), Next()) ?? 0); + + var scale = Next(); + var p = new Particle + { + Life = life, + Size = s.InitialScale == null ? Vector2.One : new Vector2(s.InitialScale.Evaluate(0, uInitial, scale, 1), s.InitialScale.Evaluate(1, uInitial, scale, 1)), + Alpha = s.InitialAlpha?.Evaluate(0, uInitial, Next(), 1) ?? 1, + Angle = MathHelper.ToRadians(Draw(s.Angle)), + Spin = s.SpinSign * MathHelper.ToRadians(Draw(s.Spin)), + Tint = Vector4.Lerp(s.TintMin, s.TintMax, Next()), + RCell = (float)random.NextDouble(), + RScale = Next(), + RDeceleration = Next(), + RSpinDeceleration = Next(), + RForce = Next(), + RAlpha = Next(), + RColour = Next(), + RStretch = Next(), + }; + (p.Position, p.Velocity) = s.Space switch + { + SimulationSpace.LocalPositionRotation => (offset, velocity), + SimulationSpace.LocalPositionOnly => (Vector3.TransformNormal(offset, world), Vector3.TransformNormal(velocity, world)), + _ => (Vector3.Transform(offset, world), Vector3.TransformNormal(velocity, world) + carried), + }; + if (s.SpawnOnTerrain) + { + var (onEffect, _) = ToEffect(s.Space, world, p.Position, p.Velocity); + (p.Position, _) = FromEffect(s.Space, world, VfxConventions.OnGround(onEffect), p.Velocity); + } + if (s.Collisions) + { + p.Bounce = Draw(s.Bounce); + p.Damping = Draw(s.Damping); + } + if (s.FlipU > 0) + p.FlipU = Next() < s.FlipU; + if (s.FlipV > 0) + p.FlipV = Next() < s.FlipV; + // A rigid model, or a quad turned as a 3D object, turns about X and Y as well as Z. + if (s.Rigid.Count > 0 || s.Orientation == QuadOrientation.Object3D) + { + Vector2 Draw2((Vector2 Min, Vector2 Max) range) => new(MathHelper.Lerp(range.Min.X, range.Max.X, Next()), MathHelper.Lerp(range.Min.Y, range.Max.Y, Next())); + p.TurnXy = Draw2(s.TurnXy) * (MathF.PI / 180); + p.SpinXy = s.SpinSignXy * Draw2(s.SpinXy) * (MathF.PI / 180); + if (s.Rigid.Count > 0) + p.Model = Math.Min((int)(Next() * s.Rigid.Count), s.Rigid.Count - 1); + } + p.Id = e.NextId++; + e.Particles.Add(p); + } + + // world: the emitter's matrix at the end of the step, which a collision is tested against and a vector field rides. + static void Move(EmitterRuntime e, float dt, Matrix world, float age) + { + var s = e.Settings; + var field = Field(e, world, age); + for (var i = e.Particles.Count - 1; i >= 0; i--) + { + var p = e.Particles[i]; + p.Age += dt; + if (p.Age >= p.Life) + { + if (s.Events.Count > 0) + e.Dead.Add(p); + e.Particles.RemoveAt(i); + continue; + } + + var v = p.Age / p.Life; + var force = Vector3.Zero; + foreach (var curve in s.Forces) + force += Vector(curve, v, p.RForce) * VfxConventions.ForceToMetres; + var push = Vector3.Zero; + if (field is var (volume, toParticles, toField, size)) + { + push = Vector3.TransformNormal(volume.Sample(Vector3.Transform(p.Position, toField) / size + new Vector3(0.5f)), toParticles) + * (s.FieldStrength?.Evaluate(0, v, p.RForce) ?? 0); + force += push; + } + // Exact for a constant force, so a path under one is the analytic path. + if (!s.SkipPositionUpdate) + p.Position += p.Velocity * dt + force * (dt * dt / 2); + p.Velocity = (p.Velocity + force * dt) * VfxConventions.Drag(s.Deceleration?.Evaluate(0, v, p.RDeceleration) ?? 0, dt); + if (field != null && s.FieldTightness?.Evaluate(0, v, p.RForce) is > 0 and var tightness) + p.Velocity = Vector3.Lerp(p.Velocity, push, Math.Min(tightness, 1)); + if (s.MaxSpeed > 0 && p.Velocity.LengthSquared() > s.MaxSpeed * s.MaxSpeed) + p.Velocity = Vector3.Normalize(p.Velocity) * s.MaxSpeed; + p.Angle += p.Spin * dt; + p.TurnXy += p.SpinXy * dt; + var spinDrag = VfxConventions.Drag(s.SpinDeceleration?.Evaluate(0, v, p.RSpinDeceleration) ?? 0, dt); + p.Spin *= spinDrag; + p.SpinXy *= spinDrag; + if (s.Collisions && !s.SkipPositionUpdate) + Collide(s.Space, world, ref p); + e.Particles[i] = p; + } + } + + // VectorField*: read as Unreal's vector fields, whose .fga files CA's came from (one still carries the extension). The + // volume fills a cube of edge Size, centred at Position and turned by Orientation in the emitter's frame, all three + // along the emitter's life; it repeats outside itself. Where a particle is, the field times Strength pushes it (m/s2), + // and Tightness pulls its velocity to that same vector, both along the particle's life. Null when there is none, or + // Size is 0 (100 vanilla emitters). toParticles: the field's frame in the particles' space; toField: its inverse. + static (VfxVectorField Volume, Matrix ToParticles, Matrix ToField, float Size)? Field(EmitterRuntime e, Matrix world, float age) + { + var s = e.Settings; + if (s.Field == null) + return null; + float U(bool loop) => VfxConventions.EmitterTime(age, e.Life, loop); + var size = s.FieldSize?.Evaluate(0, U(s.LoopFieldSize), e.RTransform) ?? 0; + if (!(size > 0)) + return null; + var frame = VfxConventions.Rotation(Vector(s.FieldOrientation, U(s.LoopFieldOrientation), e.RTransform)) + * Matrix.CreateTranslation(Vector(s.FieldPosition, U(s.LoopFieldPosition), e.RTransform)); + var toParticles = s.Space switch + { + SimulationSpace.LocalPositionRotation => frame, + SimulationSpace.LocalPositionOnly => frame * world * Matrix.CreateTranslation(-world.Translation), + _ => frame * world, + }; + return (s.Field, toParticles, Matrix.Invert(toParticles), size); + } + + // EnableCollisions: the preview's one collider is the ground, y = 0 in the effect's space. + static void Collide(SimulationSpace space, Matrix world, ref Particle p) + { + var (position, velocity) = ToEffect(space, world, p.Position, p.Velocity); + if (position.Y >= 0) + return; + position.Y = 0; + if (velocity.Y < 0) + velocity = VfxConventions.Bounce(velocity, p.Bounce, p.Damping); + (p.Position, p.Velocity) = FromEffect(space, world, position, velocity); + } + + // A particle between the space it lives in and the effect's, where it is drawn and where the ground is. + static (Vector3 Position, Vector3 Velocity) ToEffect(SimulationSpace space, Matrix world, Vector3 position, Vector3 velocity) => space switch + { + SimulationSpace.LocalPositionRotation => (Vector3.Transform(position, world), Vector3.TransformNormal(velocity, world)), + SimulationSpace.LocalPositionOnly => (position + world.Translation, velocity), + _ => (position, velocity), + }; + + static (Vector3 Position, Vector3 Velocity) FromEffect(SimulationSpace space, Matrix world, Vector3 position, Vector3 velocity) + { + if (space != SimulationSpace.LocalPositionRotation) + return space == SimulationSpace.LocalPositionOnly ? (position - world.Translation, velocity) : (position, velocity); + var inverse = Matrix.Invert(world); + return (Vector3.Transform(position, inverse), Vector3.TransformNormal(velocity, inverse)); + } + + /// An emitter's live particles as drawn now, in the effect's model space. + public static IEnumerable States(EmitterRuntime e) => e.Particles.Select(p => State(e, p, e.World)); + + // One particle as drawn, given its emitter's matrix; a particle event's emitter takes its particle's tint on top. + static ParticleState State(EmitterRuntime e, Particle p, Matrix world) + { + var s = e.Settings; + var scale = new Vector3(world.M11, world.M12, world.M13).Length(); + var v = p.Age / p.Life; + var (position, velocity) = ToEffect(s.Space, world, p.Position, p.Velocity); + var size = p.Size * scale * (s.Scale == null ? Vector2.One : new Vector2(s.Scale.Evaluate(0, v, p.RScale, 1), s.Scale.Evaluate(1, v, p.RScale, 1))); + var colour = Vector(s.Colour, v, p.RColour, 1) * new Vector3(p.Tint.X, p.Tint.Y, p.Tint.Z) * new Vector3(e.Tint.X, e.Tint.Y, e.Tint.Z) + * VfxConventions.Hdr(s.Colour?.Evaluate(3, v, p.RColour, 1) ?? 1, s.EmissiveMul); + var alpha = p.Alpha * (s.Alpha?.Evaluate(0, VfxConventions.AlphaLife(v, s.AlphaStartOffset, s.AlphaEndOffset, p.RAlpha), p.RAlpha, 1) ?? 1) * p.Tint.W * e.Tint.W; + var cells = s.CellsX * s.CellsY; + var (cell, next, blend) = s.Animated ? VfxConventions.FlipbookFrame(s, v, p.Age, MathHelper.Lerp(s.Loops.X, s.Loops.Y, v), p.RCell) + : (s.RandomCell ? Math.Min((int)(p.RCell * cells), cells - 1) : s.Cell, 0, 0f); + return new ParticleState(position, velocity, size, p.Angle, colour, alpha, s.Stretch?.Evaluate(0, v, p.RStretch) ?? 0, cell, p.FlipU, p.FlipV, next, s.CellBlend ? blend : 0, + s.AlphaCrush?.Evaluate(0, v, p.RAlpha) ?? 0, p.TurnXy, p.Model, p.Age); + } + + static Vector3 Vector(VfxCurve? curve, float t, float random, float fallback = 0) => curve == null + ? new Vector3(fallback) + : new Vector3(curve.Evaluate(0, t, random, fallback), curve.Evaluate(1, t, random, fallback), curve.Evaluate(2, t, random, fallback)); + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxConventions.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxConventions.cs new file mode 100644 index 000000000..441148243 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxConventions.cs @@ -0,0 +1,280 @@ +using System.Text.RegularExpressions; +using Microsoft.Xna.Framework; + +namespace GameWorld.Core.Vfx +{ + /// + /// The readings of the vfx format that are guesses rather than measurements, so calibrating against + /// Terry (CA's battle map editor) changes them here. This class holds some, and the + /// rest, which live where they are used: the crush, gradient and light-pool readings in + /// Shaders/Particle.fx, the vector field in ParticleSimulator.Field, the rigid draw in + /// ParticleRenderItem.DrawRigid, the persistent clock in SuperView's VfxEffectInstance.EffectTime, and + /// two in ParticleSimulator's structure: forces are accelerations in the particle's own space (the + /// emitter's axes for LOCAL_SPACE_POSITION_ROTATION, world axes otherwise), and a quad's size is + /// ParticleInitialScale x ParticleScale as its full width and height. + /// + public static class VfxConventions + { + /// ForceA/B/C are cm/s2 while speeds and sizes are metres: falling debris carries -981 (156 vanilla + /// emitters), rocks -1471.5, sand -490.5 and -1962, against 101 uses of 9.81; debris launched at 5-8 m/s under + /// -981 arcs and lands within its 1.5 s life. Measured 2026-10-11. + public const float ForceToMetres = 0.01f; + + /// The longest pre-warm simulated, in seconds. Vanilla's StartTime goes down to -20. + public const float MaxPreWarm = 30; + + /// A reference's transform: scale, then its rotation, then its position. + public static Matrix ReferenceMatrix(float scale, Vector3 rotationDegrees, Vector3 position) => + Matrix.CreateScale(scale) * Rotation(rotationDegrees) * Matrix.CreateTranslation(position); + + /// Rotations (references, EmitterOrientation): about X, then Y, then Z, in degrees. + public static Matrix Rotation(Vector3 degrees) => + Matrix.CreateRotationX(MathHelper.ToRadians(degrees.X)) * Matrix.CreateRotationY(MathHelper.ToRadians(degrees.Y)) * Matrix.CreateRotationZ(MathHelper.ToRadians(degrees.Z)); + + /// Spawn direction: SpawnAngle is the azimuth about Y from +X, SpawnSpread the elevation above the XZ plane (90 is straight up). + public static Vector3 Direction(float azimuthDegrees, float elevationDegrees) + { + var azimuth = MathHelper.ToRadians(azimuthDegrees); + var elevation = MathHelper.ToRadians(elevationDegrees); + return new Vector3(MathF.Cos(elevation) * MathF.Cos(azimuth), MathF.Sin(elevation), MathF.Cos(elevation) * MathF.Sin(azimuth)); + } + + /// An absent SpawnAngle is every azimuth. 8,908 of vanilla's 11,722 continuous_mesh emitters omit it, and + /// where present it is an azimuth range (0..360 in 802, -180..180 in 366); measured 2026-10-11. + public static readonly Vector2 EveryAzimuth = new(0, 360); + + /// SpawnArc is the spawn ring's start and end angle in degrees, in the XZ plane; 0,0 is the whole ring. + public static (float From, float To) RingArc(Vector2 spawnArc) => spawnArc == Vector2.Zero ? (0, 360) : (spawnArc.X, spawnArc.Y); + + /// SpawnInheritedSpeed is a plain share of the emitter's velocity (332 of its 1,385 vanilla emitters carry 0.5, + /// nearly all lie in 0..1), given to world-space particles only: a local-space particle already rides with its emitter. + /// + public static Vector3 Inherited(Vector3 emitterVelocity, float share) => emitterVelocity * share; + + /// SpawnInheritedDirectionBlend (146 vanilla emitters, 0.5 or 1 on 100 of them): a particle's spawn direction + /// turned that share of the way toward the way its emitter is moving, keeping its length. Read as a blend, so held to + /// 0..1 (three emitters say 200, one -0.1); a still emitter changes nothing. Not measured in game. + public static Vector3 InheritDirection(Vector3 direction, Vector3 motion, float blend) + { + if (blend <= 0 || motion.LengthSquared() < 1e-12f) + return direction; + var turned = Vector3.Lerp(Vector3.Normalize(direction), Vector3.Normalize(motion), Math.Min(blend, 1)); + return turned.LengthSquared() < 1e-12f ? direction : Vector3.Normalize(turned) * direction.Length(); + } + + /// The turn that takes +Y, the way a vfx emitter fires (SpawnSpread 90 is straight up), to a direction: how + /// orientation_by_velocity (442 vanilla particle events) is read. Not measured in game. + public static Matrix UpTo(Vector3 direction) + { + var axis = Vector3.Cross(Vector3.Up, direction); + var cos = Math.Clamp(Vector3.Dot(Vector3.Up, direction), -1, 1); + if (axis.LengthSquared() < 1e-12f) + return cos > 0 ? Matrix.Identity : Matrix.CreateRotationX(MathF.PI); + return Matrix.CreateFromAxisAngle(Vector3.Normalize(axis), MathF.Acos(cos)); + } + + /// The preview's terrain is flat ground at y = 0 in the effect's space. ParticlesSpawnOnTerrain (1,503 vanilla + /// emitters) drops the spawn point onto it. ParticlesFollowTerrain (1,073) is read as holding a particle's height above + /// the ground as it moves, which on flat ground changes nothing; pinning it to the ground is the other reading. + public static Vector3 OnGround(Vector3 position) => new(position.X, 0, position.Z); + + /// A particle falling into the ground leaves it with its fall scaled by CollisionBouncingFactor (0..1.5 in + /// vanilla) and its slide cut by CollisionDampingFactor (0..2.2), read as the share lost and clamped to 0..1. + public static Vector3 Bounce(Vector3 velocity, float bounce, float damping) + { + var keep = 1 - Math.Clamp(damping, 0, 1); + return new Vector3(velocity.X * keep, -velocity.Y * bounce, velocity.Z * keep); + } + + /// vs_flip_uv: flip_u and flip_v are each a particle's chance of a mirrored texture (0.5 in 4,217 and 3,413 of + /// 4,736 vanilla chains; above 1 is always), and uv_orientation a quarter turn of it (28 chains), the turn first. Returns + /// the texture coordinate, 0..1 with v down, at a quad corner given x right and y up, 0..1. + public static (float U, float V) QuadUv(float x, float y, bool flipU, bool flipV, int turn) + { + var (u, v) = turn switch { 1 => (y, x), -1 => (1 - y, 1 - x), _ => (x, 1 - y) }; + return (flipU ? 1 - u : u, flipV ? 1 - v : v); + } + + /// CullDistances and ParticleCullDistances, metres from the camera: alpha fades in from x to y and out from z to + /// w, linearly; a band of no width is a hard edge. ParticleCullDistances sets a near band on 8,882 of 12,408 vanilla + /// emitters, so a close camera sees particles thin out around it. + public static float CullFade(float distance, Vector4 cull) + { + var near = cull.Y > cull.X ? Math.Clamp((distance - cull.X) / (cull.Y - cull.X), 0, 1) : distance >= cull.X ? 1 : 0; + var far = cull.W > cull.Z ? Math.Clamp((cull.W - distance) / (cull.W - cull.Z), 0, 1) : distance <= cull.W ? 1 : 0; + return near * far; + } + + /// vs_rendersize_limiter (985 vanilla chains, nearly all small embers, sparks and droplets): pixelsize_limit is + /// read as the fewest pixels a particle is drawn across, so a far spark stays visible, rather than a size below which it + /// is culled. Returns what its size is multiplied by; pixelScale is the pixels a metre covers a metre from the camera. + public static float MinPixelGrowth(float size, float distance, float pixelScale, float minPixels) + { + if (minPixels <= 0 || pixelScale <= 0 || size <= 0) + return 1; + return Math.Max(1, minPixels / (size / Math.Max(distance, 1e-3f) * pixelScale)); + } + + /// A particle's colour is HDR. curve_colour's fourth output, brightness, and ps_emissive's emissive_mul (556 vanilla + /// chains, 1 to 21 and past) are read as plain multipliers on the unlit colour; a negative brightness is 0. Brightness keys + /// are 1 in 10,338 places, 0 in 597 (286 of 13,353 curves fade to dark), and run past 50 to 5,000. How far the game's + /// exposure brings those values down is not measured: the preview draws them saturated and blooms them as + /// says. + public static float Hdr(float brightness, float emissiveMul) => Math.Max(0, brightness) * emissiveMul; + + /// CA's bloom bright pass, fx_hdr_tone_mapper's ps_high_pass_luminance_cutoff: a pixel feeds the bloom by its + /// colour times saturate(luminance - g_bloom_threshold), times g_bloom_particle_strength where particles are. The glow pass + /// weighs what each particle draws so: 4 is bloom_threshold in 83 of the 126 lighting blocks of vanilla's + /// weather/battle environments (4.5 in 34, 1 to 4.7 in the rest), 0.5 bloom_particle_strength in all 126. CA weighs the + /// whole scene, so particles over 4 only where they overlap glow there and not here. + public const float BloomThreshold = 4f; + public const float BloomParticleStrength = 0.5f; + + /// ps_soft_edge (6,893 vanilla chains), as CA's ps_main reads it: a particle fades in, linearly, over + /// in front of what lies behind it. Here against the preview's ground, world y = 0: the gap is + /// how far the ray from the camera through a quad corner runs on before it reaches the ground, and a corner under the ground + /// is hidden, as terrain hides it. Against the solid scene, Shaders/Particle fades each pixel by its view depth in front of + /// the depth the engine draws first. size: the particle's smaller side, metres. + public static float SoftEdge(Vector3 corner, Vector3 camera, float softFactor, float strength, float size) + { + if (softFactor <= 0) + return 1; + if (corner.Y < 0) + return 0; + var ray = corner - camera; + var down = ray.LengthSquared() > 1e-12f ? -Vector3.Normalize(ray).Y : 0; + return down <= 0 ? 1 : Math.Clamp(corner.Y / down / SoftDistance(softFactor, strength, size), 0, 1); + } + + /// How far ps_soft_edge fades over, metres: soft_factor (1.5 in 1,684 vanilla chains, 1 in 1,489, 0.2 to 5), shrunk + /// for a particle under a metre across by scale_strength (1 in 6,610), soft_factor x lerp(1, its smaller side, strength), + /// and a millimetre more, as CA's ps_main has it. + public static float SoftDistance(float softFactor, float strength, float size) => + softFactor * MathHelper.Lerp(1, Math.Clamp(size, 0, 1), strength) + 0.001f; + + /// DEFERRED_DECAL (568 vanilla emitters), DEFERRED_DECAL_EMISSIVE (399) and SEA_OVERLAY (11) project onto what lies + /// under them, and none carries an orientation_mode. The preview lays each quad flat on its ground, world y = 0, under the + /// particle and a centimetre clear of the grid, turned by the particle's angle. + public static Vector3 Decal(Vector3 worldPosition) => new(worldPosition.X, 0.01f, worldPosition.Z); + + /// DISTORTION (685 vanilla emitters) draws no colour: its ps_texture names only a normal map, and its alpha curve + /// fades it. CA's ps_distortion_alphablend writes the map's x (alpha) and y (green) into a buffer, and fx_hdr_to_screen moves + /// the finished scene by this share of the view per unit of it, in screen space, at any distance. One particle of alpha a + /// and tilt t moves it DistortionShare x (a t)^2 x t: the particle writes a t as the buffer's cover, and the screen pass + /// weighs the shift by the cover. That weighing is read off both of CA's blend modes agreeing, not off the blend state. + public const float DistortionShare = 0.02f; + + /// continuous_rigid (2,925 vanilla emitters): each particle is a model, a wsmodel in 2,745 of 2,936 RigidData + /// entries. PivotPoint is read as a share of the model's box (0.5, 0.5, 0.5 in 2,701 entries: its middle) put on the + /// particle; the particle's width scales it evenly, and it turns about X, then Y, then Z (radians), as references do, + /// then by its emitter's frame (RigidFrame). + public static Matrix RigidMatrix(BoundingBox box, Vector3 pivot, float scale, Vector3 turn, Matrix frame, Vector3 position) => + Matrix.CreateTranslation(-(box.Min + pivot * (box.Max - box.Min))) * Matrix.CreateScale(scale) + * Matrix.CreateRotationX(turn.X) * Matrix.CreateRotationY(turn.Y) * Matrix.CreateRotationZ(turn.Z) * frame * Matrix.CreateTranslation(position); + + static readonly Regex s_standardShader = new(@"^shaders/(system/)?(weighted\d*|rigid)_(character|default|standard)(_\d+)?(_alpha|_emissive)?\.xml\.shader$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// A material's shader that the preview's PBR materials do not stand in for: any but CA's character, default and + /// standard ones. Most of vanilla's rigid VFX models name one (the dissolves, panners and unlit vortices, Tidecall's water), + /// and the preview cannot run it. + public static bool IsCustomShader(string shader) => shader.Length > 0 && !s_standardShader.IsMatch(shader.Replace('\\', '/')); + + /// How far through its AnimFile a rigid particle is: played at AnimSpeed from its birth, looping, since most of + /// vanilla's 118 animated entries are run cycles, idles and tentacles. Read as a playback rate; not measured in game. + public static float AnimShare(float age, float speed, float length) + { + if (length <= 0) + return 0; + var u = age * speed / length; + return u - MathF.Floor(u); + } + + /// The turn a rigid particle takes from its emitter's matrix, without its place or scale: ParticleRelativeSpawnOrientation + /// is RELATIVE_TO_VFX_UP_AXIS or unset on 2,853 of vanilla's 2,924 rigid emitters. RELATIVE_TO_TERRAIN_NORMAL (71) stands the + /// model on the ground, the preview's flat one, so it keeps only the emitter's heading. + public static Matrix RigidFrame(Matrix frame, bool onTerrain) + { + var forward = onTerrain ? new Vector3(frame.Forward.X, 0, frame.Forward.Z) : frame.Forward; + if (forward.LengthSquared() < 1e-12f) + return Matrix.Identity; + return Matrix.CreateWorld(Vector3.Zero, Vector3.Normalize(forward), onTerrain ? Vector3.Up : Vector3.Normalize(frame.Up)); + } + + /// SpawnAreaBox is the box's full size, centred on the emitter. + public static Vector3 BoxOffset(Vector3 size, Vector3 random01) => (random01 - new Vector3(0.5f)) * size; + + /// A curve over the emitter's life: age over LifeTime, wrapped when its LoopAnim flag is set, held at the end otherwise. + public static float EmitterTime(float age, float lifeTime, bool loop) + { + if (lifeTime <= 0) + return 0; + var u = age / lifeTime; + return loop ? u - MathF.Floor(u) : Math.Min(u, 1); + } + + /// Bursts: every BurstInterval seconds, the emitter spawns for the first BurstIntervalLength of them. + public static bool InBurst(float age, float interval, float length) => interval <= 0 || age % interval < length; + + /// ParticleDeceleration and ParticleSpinDeceleration as drag: the speed loses that fraction per second, exponentially. + public static float Drag(float deceleration, float dt) => MathF.Exp(-Math.Max(0, deceleration) * dt); + + /// A reference's lifetime biases add seconds. + public static float Biased(float value, float bias) => value + bias; + + /// ParticleSpinDirectionZ: CW turns the quad clockwise as the camera sees it. + public static float SpinSign(string? direction) => direction == "CW" ? -1 : 1; + + /// additive_blend_mul: 0 blends by alpha, 1 adds, between mixes the two; above 1 it also brightens. + public static (float AlphaWeight, float Brightness) Blend(float additiveMul) => (1 - Math.Clamp(additiveMul, 0, 1), Math.Max(1, additiveMul)); + + /// A DEFERRED_DYNAMIC_LIGHT particle's share of the models' sun per unit of colour times alpha: CA's + /// ps_main_deferred_dynamic_light lights with colour x alpha x 2500 where the models' sun is get_sun_colour() x + /// unchartedSunFactor, 20000 x 3, both then taking sin^2(0.5 deg). Read off the shader, not assumed. Times the battle's + /// dynamic_light_scale, which no shader reads, so the game puts it on the lights themselves: 0.1 in 93 of the 126 lighting + /// blocks of vanilla's weather/battle environments, between 0.03 and 0.3 in the rest. + public const float LightShare = 2500f / 60000f * 0.1f; + + /// The ground a light's pool lies on, which the preview does not draw: a mid grey of albedo 0.18. CA's light lights + /// the terrain's own albedo out of its gbuffer. + public const float GroundAlbedo = 0.18f; + + /// velocity_stretch_mul lengthens a velocity-aligned quad by speed x multiplier, up to velocity_stretch_cap times. + public static float Stretch(float speed, float multiplier, float cap) => Math.Clamp(1 + speed * multiplier, 1, cap > 0 ? cap : float.MaxValue); + + /// vs_animated_texture: no_loops is how many times the strip plays over a particle's life + /// (using_particle_lifetime) or per second (fixed_animation_speed), running from its start to its end over the life, as + /// CA's lerps it by the life fraction; without loop it holds the last cell. cell_blend (4,408 + /// of 4,935 vanilla flipbooks) mixes each cell into the next by how far the particle is between them; the next after the + /// last is the first when the strip loops, the last again when it holds. + /// vs_animated_texture's rows, as CA's has them: row_restricted plays the cells of row row_restriction; + /// randomize_row starts on the row the particle's random fraction picks and, without row_restricted, plays the whole sheet + /// from there. CA's cell then runs off the sheet; it is taken round to the first, as a repeating texture would. + /// randomize_cell starts the particle that far along its strip, by the same random fraction. + /// vs_vertex_alpha's alpha_START_offset and alpha_END_offset, as CA's map_range has them: curve_alpha is read at the life + /// fraction squeezed into the window from the start offset to 1 less the end offset, each lerped by the particle's alpha random, + /// and holds its ends outside it. + public static float AlphaLife(float life, Vector2 start, Vector2 end, float random) + { + var (from, to) = (MathHelper.Lerp(start.X, start.Y, random), 1 - MathHelper.Lerp(end.X, end.Y, random)); + return Math.Clamp((life - from) / Math.Max(to - from, 1e-6f), 0, 1); + } + + public static (int Cell, int Next, float Blend) FlipbookFrame(EmitterSettings s, float lifeFraction, float ageSeconds, float loops, float random) + { + var cells = s.CellsX * s.CellsY; + var (cell, next, blend) = FlipbookFrame(s.RowRestricted ? s.CellsX : cells, lifeFraction, ageSeconds, loops, s.FixedSpeed, s.FlipbookLoop, s.RandomCell ? random : 0); + var start = (s.RandomRow ? Math.Min((int)(random * s.CellsY), s.CellsY - 1) : s.RowRestricted ? s.Row : 0) * s.CellsX; + return ((start + cell) % cells, (start + next) % cells, blend); + } + + public static (int Cell, int Next, float Blend) FlipbookFrame(int cells, float lifeFraction, float ageSeconds, float loops, bool fixedSpeed, bool loop, float offset = 0) + { + var f = (fixedSpeed ? ageSeconds : lifeFraction) * loops + offset; + f = loop ? f - MathF.Floor(f) : Math.Clamp(f, 0, 0.9999f); + var position = f * cells; + var cell = Math.Min((int)position, cells - 1); + return (cell, loop ? (cell + 1) % cells : Math.Min(cell + 1, cells - 1), Math.Clamp(position - cell, 0, 1)); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxEffectNode.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxEffectNode.cs new file mode 100644 index 000000000..6290eb20d --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxEffectNode.cs @@ -0,0 +1,23 @@ +using GameWorld.Core.Components.Rendering; +using GameWorld.Core.SceneNodes; +using Microsoft.Xna.Framework; + +namespace GameWorld.Core.Vfx +{ + /// Queues an effect's particles in the transparent pass every frame. IDisposable, because the scene + /// disposes its nodes when the editor closes and reaches only the ones that are. + public class VfxEffectNode : GroupNode, IDrawableItem, IDisposable + { + public ParticleRenderItem RenderItem { get; } + + public VfxEffectNode(string name, ParticleRenderItem renderItem) : base(name) => RenderItem = renderItem; + + public void Render(RenderEngineComponent renderEngine, Matrix parentWorld) + { + RenderItem.World = ModelMatrix * parentWorld; + renderEngine.AddRenderItem(RenderBuckedId.Transparent, RenderItem); + } + + public void Dispose() => RenderItem.Dispose(); + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxMeshCache.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxMeshCache.cs new file mode 100644 index 000000000..f5ddd7b92 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxMeshCache.cs @@ -0,0 +1,171 @@ +using GameWorld.Core.Animation; +using GameWorld.Core.Rendering.Geometry; +using GameWorld.Core.Rendering.Materials.Capabilities; +using GameWorld.Core.Rendering.Materials.Shaders; +using GameWorld.Core.SceneNodes; +using Microsoft.Xna.Framework; +using Shared.Core.PackFiles; +using Shared.GameFormats.Animation; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.RigidModel; +using Shared.GameFormats.WsModel; + +namespace GameWorld.Core.Vfx +{ + /// A model's LOD 0 meshes, each with its placement in the model, the box around them, and its skeleton's name. + public record VfxModel(IReadOnlyList<(MeshObject Geometry, IShader Material, Matrix Local)> Meshes, BoundingBox Box, string Skeleton = ""); + + /// + /// The models continuous_rigid particles draw, loaded once per editor through the editor's own mesh loader, which returns + /// null for a model not in the packs or that did not load (and says so itself). Owns the geometry it loaded; the + /// materials' shaders belong to the app. + /// + public sealed class VfxMeshCache : IDisposable + { + static readonly VfxModel s_none = new([], new BoundingBox()); + static readonly Matrix[] s_bindPose = Enumerable.Repeat(Matrix.Identity, 256).ToArray(); + readonly Func _load; + readonly Dictionary _models = []; + readonly Func? _loadAnimation; + readonly Action? _log; + readonly Dictionary<(string Skeleton, string Anim), (GameSkeleton Skeleton, AnimationClip Clip)?> _clips = []; + + /// loadAnimation: an AnimFile or a skeleton (animations/skeletons/name.anim) by path, null (said by itself) when + /// missing; without it every model holds its bind pose. + public VfxMeshCache(Func load, Func? loadAnimation = null, Action? log = null) => + (_load, _loadAnimation, _log) = (load, loadAnimation, log); + + static string Key(string path) => path.Replace('/', '\\').ToLowerInvariant(); + + public VfxModel Get(string path) + { + var key = Key(path); + if (_models.TryGetValue(key, out var known)) + return known; + + var root = _load(path); + var models = root == null ? [] : root is Rmv2ModelNode single ? [single] : SceneNodeHelper.GetChildrenOfType(root); + var meshes = models + .SelectMany(model => model.GetLodNodes().OrderBy(lod => lod.LodValue).Take(1)) + .SelectMany(lod => SceneNodeHelper.GetChildrenOfType(lod)) + .Select(BindPose) + .Select(mesh => (mesh.Geometry, (IShader)mesh.Material, Matrix.CreateTranslation(mesh.PivotPoint) * mesh.ModelMatrix)) + .ToList(); + return _models[key] = meshes.Count == 0 ? s_none + : new VfxModel(meshes, meshes.Select(x => x.Geometry.BoundingBox).Aggregate(BoundingBox.CreateMerged), + meshes.Select(x => x.Geometry.SkeletonName).FirstOrDefault(x => !string.IsNullOrEmpty(x)) ?? ""); + } + + /// Poses a model's meshes for its next draw: anim sampled at age (seconds since the particle's birth) at speed, + /// looping (VfxConventions.AnimShare); the bind pose when there is no anim or it, or the model's skeleton, does not load. + public void Pose(VfxModel model, string? anim, float age, float speed) + { + var clip = anim == null ? null : Clip(model.Skeleton, anim); + var transforms = s_bindPose; + if (clip is var (skeleton, animation)) + { + var frame = AnimationSampler.Sample(VfxConventions.AnimShare(age, speed, animation.PlayTimeInSec), skeleton, animation); + transforms = s_bindPose.ToArray(); + for (var i = 0; i < Math.Min(frame.BoneTransforms.Count, transforms.Length); i++) + transforms[i] = frame.BoneTransforms[i].WorldTransform; + } + foreach (var (geometry, material, _) in model.Meshes) + if ((material as CapabilityMaterial)?.TryGetCapability() is { } animated) + (animated.AnimationTransforms, animated.ApplyAnimation) = (transforms, clip != null && geometry.VertexFormat != UiVertexFormat.Static); + } + + (GameSkeleton, AnimationClip)? Clip(string skeletonName, string anim) + { + var key = (skeletonName, Key(anim)); + if (_clips.TryGetValue(key, out var known)) + return known; + _clips[key] = null; + if (_loadAnimation == null || skeletonName.Length == 0) + return null; + if (_loadAnimation($"animations/skeletons/{skeletonName}.anim") is not { } skeletonFile || _loadAnimation(anim) is not { } animFile) + return null; + try + { + var skeleton = new GameSkeleton(skeletonFile, null!); + return _clips[key] = (skeleton, new AnimationClip(animFile, skeleton)); + } + catch (Exception e) + { + _log?.Invoke($"rigid model animation {anim} does not play on skeleton {skeletonName}: {e.Message}"); + return null; + } + } + + /// The custom shaders (VfxConventions.IsCustomShader) a wsmodel's materials name: the preview draws those meshes + /// plain and lit. Empty for any other model, and for a wsmodel that does not read, whose load says so. + public static IReadOnlyList CustomShaders(IPackFileService packFileService, string path) + { + if (!path.EndsWith(".wsmodel", StringComparison.OrdinalIgnoreCase) || packFileService.FindFile(path) is not { } file) + return []; + try + { + return new WsModelFile(file).MaterialList + .Select(x => packFileService.FindFile(x.MaterialPath)).OfType() + .Select(x => new WsModelMaterialFile(x).ShaderPath) + .Where(VfxConventions.IsCustomShader).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + catch (Exception) + { + return []; + } + } + + /// Loads an AnimFile or a skeleton from the loaded packs; missing or unreadable, null and one log line. + public static Func PackAnimationLoader(IPackFileService packFileService, Action log) => path => + { + if (packFileService.FindFile(path) is not { } file) + { + log($"{path} is not in the loaded packs, so the rigid model holds its bind pose"); + return null; + } + try + { + return AnimationFile.Create(file); + } + catch (Exception e) + { + log($"{path} did not load, so the rigid model holds its bind pose: {e.Message}"); + return null; + } + }; + + // A mesh node fills its material's bone transforms when it renders itself; drawn from here it never does, and a + // material with none set throws. The model holds its bind pose. + static Rmv2MeshNode BindPose(Rmv2MeshNode mesh) + { + if (mesh.Material.TryGetCapability() is { } animation) + (animation.AnimationTransforms, animation.AnimationWeightCount, animation.ApplyAnimation) = (s_bindPose, mesh.Geometry.WeightCount, false); + return mesh; + } + + /// A file changed in a pack: let go of what was loaded from it, so the next draw loads the new one. A wsmodel is + /// built from geometry and material files this cache does not track, so a change to any model file lets go of every model. + public void Forget(string path) + { + var key = Key(path); + foreach (var clip in _clips.Keys.Where(x => x.Anim == key || Key($"animations/skeletons/{x.Skeleton}.anim") == key).ToList()) + _clips.Remove(clip); + // Any model file reloads every model; track the files each wsmodel names if reloads get slow. + var models = s_modelFiles.Any(key.EndsWith) ? _models.Keys.ToList() : [key]; + foreach (var model in models) + if (_models.Remove(model, out var loaded)) + foreach (var mesh in loaded.Meshes) + mesh.Geometry.Dispose(); + } + + static readonly string[] s_modelFiles = [".rigid_model_v2", ".wsmodel", ".material"]; + + public void Dispose() + { + foreach (var mesh in _models.Values.SelectMany(x => x.Meshes)) + mesh.Geometry.Dispose(); + _models.Clear(); + _clips.Clear(); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxResolver.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxResolver.cs new file mode 100644 index 000000000..7466a1e2e --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxResolver.cs @@ -0,0 +1,215 @@ +using System.Xml; +using System.Xml.Linq; +using Microsoft.Xna.Framework; +using Shared.Core.PackFiles; +using Shared.GameFormats.Vfx; + +namespace GameWorld.Core.Vfx +{ + /// An enabled emitter of an effect, with what every reference on the way to it said. InfiniteLife: no reference on + /// the way says infinite_life="false", which ends an emitter that is infinite on its own (wh3_main_lib_lightsource's lights, + /// under an explosion); "true" makes nothing infinite, 659 impact and explosion references saying it over emitters that + /// all end. MaterialTexture: the colour texture its ps_custom_shader material names, if the material is in the packs and + /// names one. VectorField: the volume its VectorFieldStrId names, if that is in the packs and reads. + public record ResolvedEmitter(VfxEmitter Emitter, VfxBehaviourChain? Chain, string? SceneViewType, string File, + Matrix ReferenceTransform, float StartOffset, float SpeedMultiplier, bool InfiniteLife, VfxEmitterOverride Override, bool TrailMode = false, + string? MaterialTexture = null, VfxVectorField? VectorField = null, float? RefSpawnSpeed = null, IReadOnlyList? Events = null); + + /// A particle event with its effect resolved as a top-level one is. + public record ResolvedEvent(VfxParticleEvent Event, IReadOnlyList Emitters); + + public record VfxResolveResult(bool Found, List Emitters, List Problems); + + /// + /// Loads vfx/<name>.xml through the pack file service (so project packs and folders count the + /// same as CA's), follows its references with a cycle guard and applies their overrides; an outer + /// reference's override wins. Range values (scale, start time) take one draw each from the random. + /// + public class VfxResolver + { + private readonly IPackFileService _packFileService; + private readonly IReadOnlyDictionary _unsaved; + private readonly int _tier; + private readonly Dictionary _fields = []; + // Particle event effects, resolved once per Resolve: every emitter of an effect and of each effect it reaches would + // otherwise resolve the same ones again, level on level. Null while one is being resolved, so reaching it then is a cycle. + private readonly Dictionary?> _eventEffects = []; + + /// Bytes by Key(name) read in place of the pack's copy: an editor's unsaved file. + /// The quality tier read, 0 (Ultra) by default. + public VfxResolver(IPackFileService packFileService, IReadOnlyDictionary? unsaved = null, int tier = 0) + { + _packFileService = packFileService; + _unsaved = unsaved ?? new Dictionary(); + _tier = tier; + } + + public static string PathOf(string vfxName) + { + var name = vfxName.Trim(); + return "vfx/" + (name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? name : name + ".xml"); + } + + public VfxResolveResult Resolve(string vfxName, Random random) + { + var result = new VfxResolveResult(false, [], []); + _eventEffects.Clear(); + var top = Load(vfxName, result.Problems); + if (top == null) + return result; + + Collect(vfxName.Trim(), top, Matrix.Identity, 0, 1, true, new Dictionary(), [Key(vfxName)], random, result, []); + return result with { Found = true }; + } + + public static string Key(string vfxName) => PathOf(vfxName).ToLowerInvariant(); + + VfxFile? Load(string vfxName, List problems) + { + var unsaved = _unsaved.GetValueOrDefault(Key(vfxName)); + var file = unsaved == null ? _packFileService.FindFile(PathOf(vfxName)) : null; + if (unsaved == null && file == null) + return null; + try + { + return VfxFile.Parse(unsaved ?? file!.DataSource.ReadData(), _tier); + } + catch (XmlException e) + { + problems.Add($"{PathOf(vfxName)} did not parse: {e.Message}"); + return null; + } + catch (Exception e) // a pack rebuilt or moved on disk while it is loaded; nothing throws into the viewport + { + problems.Add($"{PathOf(vfxName)} could not be read: {e.Message}"); + return null; + } + } + + // A vfx material's first texture in a slot named for a diffuse or base colour (t_xml_particle_diffuse_2048_only, + // t_xml_diffuse_base_colour, ...). Null when the material is not in the packs or names none: the fire's carry only alpha masks. + string? ColourTexture(string? material) + { + if (string.IsNullOrEmpty(material) || _packFileService.FindFile(material) is not { } file) + return null; + try + { + using var stream = new System.IO.MemoryStream(file.DataSource.ReadData()); + return XDocument.Load(stream).Descendants("texture") + .Where(t => t.Element("slot")?.Value is { } slot && (slot.Contains("diffuse", StringComparison.OrdinalIgnoreCase) || slot.Contains("base_colour", StringComparison.OrdinalIgnoreCase))) + .Select(t => t.Element("source")?.Value.Trim()) + .FirstOrDefault(source => !string.IsNullOrEmpty(source)); + } + catch (Exception) // a broken material draws nothing, as one with no colour texture does + { + return null; + } + } + + // One read per field per resolve: 1,205 vanilla emitters share the turbulence field. + VfxVectorField? VectorField(string? path) + { + if (string.IsNullOrEmpty(path)) + return null; + if (_fields.TryGetValue(path.ToLowerInvariant(), out var known)) + return known; + VfxVectorField? field = null; + try + { + if (_packFileService.FindFile(path) is { } file) + field = VfxVectorField.Read(file.DataSource.ReadData()); + } + catch (Exception) // a pack moved on disk: the emitter runs without its field, and says so + { + } + return _fields[path.ToLowerInvariant()] = field; + } + + // A particle event's effect, resolved as a top-level one is; one already on the way here is a cycle, and not followed. + List Events(string name, VfxEmitter emitter, HashSet stack, Random random, VfxResolveResult result) + { + var events = new List(); + foreach (var particleEvent in emitter.Events ?? []) + { + var key = Key(particleEvent.Effect); + var known = _eventEffects.TryGetValue(key, out var resolved); + if (stack.Contains(key) || known && resolved == null) + { + result.Problems.Add($"{name}: particle event cycle through {particleEvent.Effect}, skipped"); + continue; + } + if (!known) + { + _eventEffects[key] = null; + var target = Load(particleEvent.Effect, result.Problems); + if (target == null) + result.Problems.Add($"{name}: particle event effect {PathOf(particleEvent.Effect)} not found, skipped"); + var child = new VfxResolveResult(true, [], result.Problems); + if (target != null) + Collect(particleEvent.Effect.Trim(), target, Matrix.Identity, 0, 1, true, new Dictionary(), [.. stack, key], random, child, []); + _eventEffects[key] = resolved = child.Emitters; + } + if (resolved!.Count > 0) + events.Add(new ResolvedEvent(particleEvent, resolved)); + } + return events; + } + + // named: the reference that reached this file lists emitters, and so plays only those, as the game does. CA's editor + // saves every emitter a library has into the reference, so one the library gained later is unnamed and silent. + void Collect(string name, VfxFile file, Matrix transform, float startOffset, float speed, bool infinite, + IReadOnlyDictionary outer, HashSet stack, Random random, VfxResolveResult result, HashSet seen, bool named = false) + { + foreach (var emitter in file.Emitters) + { + seen.Add(emitter.Id); + if (!outer.TryGetValue(emitter.Id, out var o) && named) + continue; + o ??= VfxEmitterOverride.None; + var switchedOn = !emitter.Properties.TryGetValue("Enable", out var enable) || enable.IsTrue; + if (!o.Enable || !switchedOn) + continue; + file.Chains.TryGetValue(emitter.Id, out var chain); + file.SceneViewTypes.TryGetValue(emitter.Id, out var sceneView); + var material = chain?.Find("ps_custom_shader")?.Properties.GetValueOrDefault("material")?.Attributes.GetValueOrDefault("path"); + result.Emitters.Add(new ResolvedEmitter(emitter, chain, sceneView, name, transform, startOffset, speed, infinite, o, file.TrailModes.Contains(emitter.Id), + ColourTexture(material), VectorField(emitter.Properties.GetValueOrDefault("VectorFieldStrId")?.Value), file.RefSpawnSpeed, + Events(name, emitter, stack, random, result))); + } + + foreach (var reference in file.References) + { + var key = Key(reference.Target); + if (stack.Contains(key)) + { + result.Problems.Add($"{name}: reference cycle through {reference.Target}, skipped"); + continue; + } + var target = Load(reference.Target, result.Problems); + if (target == null) + { + result.Problems.Add($"{name}: referenced effect {PathOf(reference.Target)} not found, skipped"); + continue; + } + + var overrides = new Dictionary(reference.Overrides); + foreach (var (emitterName, o) in outer) + overrides[emitterName] = o; + + var scale = MathHelper.Lerp(reference.ScaleMin, reference.ScaleMax, (float)random.NextDouble()); + var start = MathHelper.Lerp(reference.StartTimeMin, reference.StartTimeMax, (float)random.NextDouble()); + var local = VfxConventions.ReferenceMatrix(scale, reference.RotationDegrees, reference.Position); + + // An override reaches any emitter under its reference; one naming an id nothing there has does nothing. + var below = new HashSet(); + stack.Add(key); + Collect(reference.Target, target, local * transform, startOffset + start, speed * reference.SimulationSpeed, + infinite && reference.InfiniteLife, overrides, stack, random, result, below, reference.Overrides.Count > 0); + stack.Remove(key); + seen.UnionWith(below); + foreach (var missing in reference.Overrides.Keys.Where(x => !below.Contains(x))) + result.Problems.Add($"{name}: {reference.Target} has no emitter {missing}, so its override does nothing"); + } + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxTextureCache.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxTextureCache.cs new file mode 100644 index 000000000..a44b3c4a2 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxTextureCache.cs @@ -0,0 +1,47 @@ +using Microsoft.Xna.Framework.Graphics; + +namespace GameWorld.Core.Vfx +{ + /// + /// The textures of every effect in one editor, each loaded once by path and kept until the editor closes. + /// A SuperView rebuild (every click in its tag list) makes new render items; without this each one decoded + /// its textures again, about half a second for the firebox's three (measured 2026-10-11). Render items + /// never dispose what this holds. + /// + // Keyed by path alone, since the editor shares one graphics device; key by device too if it ever does not. + public sealed class VfxTextureCache : IDisposable + { + private readonly Func _load; + private readonly Dictionary _textures = new(StringComparer.OrdinalIgnoreCase); + + public VfxTextureCache(Func load) => _load = load; + + static string Key(string path) => path.Replace('/', '\\'); + + /// The texture at a path, or null when it did not load (the loader logs why). + public Texture2D? Load(GraphicsDevice device, string path) + { + if (!_textures.TryGetValue(Key(path), out var texture)) + _textures[Key(path)] = texture = _load(device, path); + return texture; + } + + /// True once a load of this path has been tried and failed. + public bool Failed(string path) => _textures.TryGetValue(Key(path), out var texture) && texture == null; + + /// Lets go of a path whose file changed, so the next draw loads it again. Render items ask on every draw + /// and keep nothing, so none is left holding the disposed texture. + public void Forget(string path) + { + if (_textures.Remove(Key(path), out var texture)) + texture?.Dispose(); + } + + public void Dispose() + { + foreach (var texture in _textures.Values) + texture?.Dispose(); + _textures.Clear(); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxVectorField.cs b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxVectorField.cs new file mode 100644 index 000000000..a60fd2e8b --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Vfx/VfxVectorField.cs @@ -0,0 +1,57 @@ +using Microsoft.Xna.Framework; + +namespace GameWorld.Core.Vfx +{ + /// + /// A vector field (vfx/vectorfields/*.dds). All 56 vanilla ones are uncompressed A16B16G16R16F volumes, 8 to 32 cells a + /// side, without mips; x runs along a row, y down the rows and z through the slices, with Y up (measured 2026-10-12 on + /// the radial, direction and xz vortex fields). + /// + public class VfxVectorField + { + const int Header = 128; + const int HalfFloatRgba = 113; // D3DFMT_A16B16G16R16F, the FourCC of every vanilla field + readonly Vector3[] _cells; + readonly int _width, _height, _depth; + + VfxVectorField(int width, int height, int depth, Vector3[] cells) => (_width, _height, _depth, _cells) = (width, height, depth, cells); + + /// Null for anything but a volume in that format. + public static VfxVectorField? Read(byte[] dds) + { + if (dds.Length < Header || dds[0] != 'D' || dds[1] != 'D' || dds[2] != 'S' || BitConverter.ToInt32(dds, 84) != HalfFloatRgba) + return null; + var (height, width, depth) = (BitConverter.ToInt32(dds, 12), BitConverter.ToInt32(dds, 16), Math.Max(1, BitConverter.ToInt32(dds, 24))); + if (width < 1 || height < 1 || Header + (long)width * height * depth * 8 > dds.Length) + return null; + var cells = new Vector3[width * height * depth]; + for (var i = 0; i < cells.Length; i++) + { + var at = Header + i * 8; + cells[i] = new Vector3((float)BitConverter.ToHalf(dds, at), (float)BitConverter.ToHalf(dds, at + 2), (float)BitConverter.ToHalf(dds, at + 4)); + } + return new VfxVectorField(width, height, depth, cells); + } + + /// The field at a point in the volume's own units, 0 to 1 across it: trilinear between cell centres, the volume + /// repeating outside itself. + public Vector3 Sample(Vector3 uvw) + { + static (int Low, int High, float Weight) Axis(float u, int cells) + { + var x = u * cells - 0.5f; + var floor = MathF.Floor(x); + var low = (int)(((long)floor % cells + cells) % cells); + return (low, (low + 1) % cells, x - floor); + } + + var (x0, x1, fx) = Axis(uvw.X, _width); + var (y0, y1, fy) = Axis(uvw.Y, _height); + var (z0, z1, fz) = Axis(uvw.Z, _depth); + Vector3 At(int x, int y, int z) => _cells[(z * _height + y) * _width + x]; + Vector3 Row(int y, int z) => Vector3.Lerp(At(x0, y, z), At(x1, y, z), fx); + Vector3 Slice(int z) => Vector3.Lerp(Row(y0, z), Row(y1, z), fy); + return Vector3.Lerp(Slice(z0), Slice(z1), fz); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Rendering/RenderEngineVfxTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Rendering/RenderEngineVfxTests.cs new file mode 100644 index 000000000..8d066698a --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Rendering/RenderEngineVfxTests.cs @@ -0,0 +1,122 @@ +using System.IO; +using GameWorld.Core.Components.Rendering; +using GameWorld.Core.Rendering; +using GameWorld.Core.Services; +using GameWorld.Core.Vfx; +using GameWorld.Core.WpfWindow; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Shared.GameFormats.Vfx; +using Test.TestingUtility.Shared; + +namespace GameWorld.Core.Test.Rendering +{ + // What the render engine hands the VFX preview's particles each frame, and what a screenshot leaves out. + [Apartment(ApartmentState.STA)] + public class RenderEngineVfxTests + { + AssetEditorTestRunner _runner; + IServiceScope _scope; + RenderEngineComponent _engine; + GraphicsDevice _device; + + [SetUp] + public void Setup() + { + _runner = new AssetEditorTestRunner(); + _scope = _runner.ServiceProvider.CreateScope(); + var game = _scope.ServiceProvider.GetRequiredService(); + game.AddComponent(_scope.ServiceProvider.GetRequiredService()); + _engine = game.AddComponent(_scope.ServiceProvider.GetRequiredService()); + _device = game.GraphicsDevice; + } + + [TearDown] + public void TearDown() => _scope.Dispose(); + + // One frame onto a 64 x 64 target, of the items `add` puts in the engine's buckets. + void Frame(Action add) + { + using var target = new RenderTarget2D(_device, 64, 64, false, SurfaceFormat.Color, DepthFormat.Depth24); + _device.SetRenderTarget(target); + add(_engine); + _engine.Draw(new GameTime()); + _engine.Update(new GameTime()); + _device.SetRenderTarget(null); + } + + // Soft particles fade against the solid scene, so a frame with particles first draws the scene's depth for them. + [Test] + public void A_frame_with_particles_hands_them_the_scenes_depth() + { + var (particles, solid) = (new List(), new List()); + Frame(e => e.AddRenderItem(RenderBuckedId.Transparent, new Witness(particles))); + Frame(e => e.AddRenderItem(RenderBuckedId.Normal, new Witness(solid))); + Assert.That(particles.Last().SceneDepth, Is.Not.Null); + Assert.That(solid.Last().SceneDepth, Is.Null, "a frame with no particles draws no depth"); + } + + // The solid scene is handed every light particle alive, not only the first. + [Test] + public void The_solid_scene_takes_every_light_particle() + { + var lights = new EmitterSettings + { + Name = "light", InfiniteLife = true, Rate = VfxCurve.Constant(60), MaxParticles = 3, ParticleLife = VfxCurve.Constant(100), Light = true, Decal = true, + }; + var simulator = new ParticleSimulator([lights], 1, _ => Matrix.Identity); + simulator.AdvanceTo(0.5f); + using var textures = new VfxTextureCache((_, _) => null); + var shader = _runner.ServiceProvider.GetRequiredService(); + using var item = new ParticleRenderItem(simulator, textures, () => shader.GetStaticEffect(ShaderTypes.Particle)); + var seen = new List(); + Frame(e => + { + e.AddRenderItem(RenderBuckedId.Transparent, item); + e.AddRenderItem(RenderBuckedId.Normal, new Witness(seen)); + }); + Assert.That(seen.Last().PointLights, Has.Count.EqualTo(3)); + } + + // A screenshot without lines leaves the grid out and keeps the wireframe, as PhotoStudio's always has; one without + // overlays too, as the VFX editor takes, leaves both out. The next frame draws both again. + [TestCase(true)] + [TestCase(false)] + public void A_screenshot_without_lines_draws_no_grid(bool overlays) + { + var folder = Path.Combine(TestContext.CurrentContext.WorkDirectory, "screenshot_guides"); + var (grid, wireframe) = (new List(), new List()); + void Add(RenderEngineComponent e) + { + e.AddRenderItem(RenderBuckedId.Grid, new Recorder(grid)); + e.AddRenderItem(RenderBuckedId.Wireframe, new Recorder(wireframe)); + } + try + { + _engine.SaveNextFrame(new SaveRenderImageSettings("frame", false, DrawLines: false, 1, folder, DrawOverlays: overlays)); + Frame(Add); + var inScreenshot = (grid.Count, wireframe.Count); + Frame(Add); + var plain = (grid.Count - inScreenshot.Item1, wireframe.Count - inScreenshot.Item2); + Assert.That(plain.Item1 > 0 && plain.Item2 > 0, $"a plain frame draws both: {plain}"); + Assert.That(inScreenshot, Is.EqualTo((0, overlays ? plain.Item2 : 0))); + } + finally + { + if (Directory.Exists(folder)) + Directory.Delete(folder, true); + } + } + + sealed class Witness(List seen) : IRenderItem + { + public void Draw(GraphicsDevice device, CommonShaderParameters parameters, RenderingTechnique technique) => seen.Add(parameters); + } + + sealed class Recorder(List drawn) : IRenderItem + { + public void Draw(GraphicsDevice device, CommonShaderParameters parameters, RenderingTechnique technique) => drawn.Add(technique); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Rendering/ScreenshotAlphaTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Rendering/ScreenshotAlphaTests.cs new file mode 100644 index 000000000..ea4fbfda4 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Rendering/ScreenshotAlphaTests.cs @@ -0,0 +1,48 @@ +using System.IO; +using System.Windows.Media.Imaging; +using GameWorld.Core.Components.Rendering; +using Microsoft.Xna.Framework; + +namespace GameWorld.Core.Test.Rendering +{ + // A screenshot keeps the viewport's light. The frame is premultiplied, as the viewport draws it, and additive light (particles, + // bloom) adds colour and no alpha: saved as it was, Archaon's flames had their colour and alpha 0 over the empty background, + // and a viewer reading the alpha showed nothing there. + internal class ScreenshotAlphaTests + { + static Color Saved(Color premultiplied) + { + using var stream = new MemoryStream(); + RenderEngineComponent.SavePng(stream, [premultiplied], 1, 1); + stream.Position = 0; + var frame = new FormatConvertedBitmap(BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad).Frames[0], + System.Windows.Media.PixelFormats.Bgra32, null, 0); + var bgra = new byte[4]; + frame.CopyPixels(bgra, 4, 0); + return new Color(bgra[2], bgra[1], bgra[0], bgra[3]); + } + + // Over black the saved pixel is what the viewport drew: the light's colour, kept as alpha from its brightness. + [Test] + public void LightOverNothing_IsSavedSeen_WithAlphaFromItsBrightness() + { + var saved = Saved(new Color(200, 100, 0, 0)); + + Assert.That(saved, Is.EqualTo(new Color(255, 128, 0, 200))); + } + + [TestCase(10, 20, 30, 255)] + [TestCase(0, 0, 0, 0)] + public void ASolidOrEmptyPixel_IsSavedAsItIs(int r, int g, int b, int a) + { + Assert.That(Saved(new Color(r, g, b, a)), Is.EqualTo(new Color(r, g, b, a))); + } + + // A half-covered edge was saved premultiplied as if straight: half its colour at half alpha, a dark fringe. + [Test] + public void AHalfCoveredEdge_IsSavedAtItsFullColour() + { + Assert.That(Saved(new Color(64, 32, 0, 128)), Is.EqualTo(new Color(128, 64, 0, 128))); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/EmitterSettingsTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/EmitterSettingsTests.cs new file mode 100644 index 000000000..bc59a9aae --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/EmitterSettingsTests.cs @@ -0,0 +1,450 @@ +using GameWorld.Core.Vfx; +using Microsoft.Xna.Framework; +using static GameWorld.Core.Test.Vfx.VfxTestFiles; + +namespace GameWorld.Core.Test.Vfx +{ + public class EmitterSettingsTests + { + // Cut from wh3_dlc23_lib_warmachines: a premultiplied smoke flipbook and an additive atlas spark. + const string SmokeChain = """"""; + const string SparksChain = """x_min,0,0;0.0,0.5,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,1.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,1;0,1"""; + + static List Read(string emitters, string chains, string stacks = "") => + new VfxResolver(Pack(("fx.xml", Xml(emitters: emitters, chains: chains, stacks: stacks)))).Resolve("fx", new Random(1)).Emitters.Select(EmitterSettings.From).ToList(); + + [Test] + public void SmokeChain_IsAPremultipliedFlipbook_InLocalSpace() + { + var smoke = Read(Emitter("smoke", """"""), SmokeChain).Single(); + Assert.Multiple(() => + { + Assert.That(smoke.Draws, Is.True); + Assert.That(smoke.Space, Is.EqualTo(SimulationSpace.LocalPositionRotation)); + Assert.That(smoke.InfiniteLife, Is.True); + Assert.That(smoke.Texture, Is.EqualTo(@"vfx\textures\smoke_dense_loop_diffuse.dds")); + Assert.That((smoke.Premultiplied, smoke.AdditiveMul), Is.EqualTo((true, 0f))); + Assert.That((smoke.Animated, smoke.CellsX, smoke.CellsY, smoke.FlipbookLoop, smoke.FixedSpeed), Is.EqualTo((true, 8, 8, true, false))); + Assert.That(smoke.Loops, Is.EqualTo(new Vector2(0.5f, 0.5f))); + Assert.That(smoke.Pivot, Is.EqualTo(new Vector2(0.5f, 0.25f))); + Assert.That(smoke.Orientation, Is.EqualTo(QuadOrientation.CameraFacing)); + }); + } + + // A reference's infinite_life="false" ends what it reaches, however deep: wh3_main_lib_lightsource's eight lights are + // all infinite, and explosions reference it with false. "true" makes nothing infinite: 659 impact and explosion + // references say it over emitters that all end. + [Test] + public void ReferenceInfiniteLife_EndsWhatItReaches_AndMakesNothingInfinite() + { + var lib = Xml(emitters: Emitter("lit") + """"""); + var mid = Xml(references: Reference("lib", infinite: true)); + var fx = Xml(references: Reference("lib", infinite: false) + Reference("lib", infinite: true) + Reference("mid", infinite: false)); + var settings = new VfxResolver(Pack(("lib.xml", lib), ("mid.xml", mid), ("fx.xml", fx))).Resolve("fx", new Random(1)).Emitters.Select(EmitterSettings.From); + Assert.That(settings.Select(x => x.InfiniteLife), Is.EqualTo(new[] { false, false, true, false, false, false })); + } + + // ps_lighting's five settings, as smoke_heavy (wh_main_lib_fire_smoke) sets them. A chain blended by alpha draws lit; an + // additive one and a decal do not, since the preview's additive reading is not CA's. + [Test] + public void Lighting_IsReadOffPsLighting_AndOnlyAnAlphaBlendedChainIsLit() + { + var chain = SmokeChain.Replace("""value="0.0"/>""", + """value="0.0"/>"""); + var smoke = Read(Emitter("smoke"), chain).Single(); + var sparks = Read(Emitter("sparks"), SparksChain).Single(); + Assert.Multiple(() => + { + Assert.That(smoke.Lighting, Is.EqualTo(new ParticleLighting(0.8f, 0, 0.4f, 0, 0.8f))); + Assert.That(smoke.Lit, Is.True); + Assert.That((sparks.Lighting != null, sparks.Lit), Is.EqualTo((true, false))); + Assert.That(new EmitterSettings { Lighting = smoke.Lighting, Decal = true }.Lit, Is.False); + }); + } + + // ps_normal: the chain's ps_texture normal map shapes its lighting. Without ps_normal the map is not read, as in CA's ps_main. + [Test] + public void NormalMap_IsReadWhereTheChainHasPsNormal() + { + var mapped = SmokeChain.Replace("""", """""")).Single(); + var without = Read(Emitter("smoke"), mapped).Single(); + Assert.That((withNormal.NormalMap, without.NormalMap), Is.EqualTo((@"vfx\textures\smoke_n.dds", (string?)null))); + } + + [Test] + public void SparksChain_IsAStretchedAdditiveAtlasCell() + { + var sparks = Read(Emitter("sparks"), SparksChain).Single(); + Assert.Multiple(() => + { + Assert.That(sparks.Space, Is.EqualTo(SimulationSpace.World), "no ParticleSimulationSpace is world space"); + Assert.That((sparks.Orientation, sparks.StretchCap), Is.EqualTo((QuadOrientation.VelocityAligned, 3f))); + Assert.That(sparks.Stretch, Is.Not.Null); + Assert.That((sparks.AtlasX, sparks.AtlasY, sparks.AtlasCell, sparks.CellsX, sparks.CellsY, sparks.RandomCell, sparks.Animated), Is.EqualTo((8, 8, 51, 2, 2, true, false))); + Assert.That((sparks.Premultiplied, sparks.AdditiveMul), Is.EqualTo((false, 1f))); + }); + } + + // A DEFERRED_DECAL paints the ground's colour, so it is laid over by its alpha whatever pre_multiplied_alpha says: + // wh3_dlc20_kho_crack_01, flagged premultiplied, bleeds brown into its clear texels and drew a brown square. An + // emissive decal is light, and keeps the flag. + [TestCase("DEFERRED_DECAL", false)] + [TestCase("DEFERRED_DECAL_EMISSIVE", true)] + public void DecalStack_PaintsTheGround_ByItsAlpha(string view, bool premultiplied) + { + var decal = Read(Emitter("decal"), SmokeChain.Replace("\"smoke\"", "\"decal\""), + $"""""").Single(); + Assert.That((decal.Decal, decal.Premultiplied), Is.EqualTo((true, premultiplied))); + } + + [Test] + public void DistortionStack_IsNotDrawn_AndSaysWhy() + { + var ripple = Read(Emitter("ripple"), SmokeChain.Replace("\"smoke\"", "\"ripple\""), + """""").Single(); + Assert.That(ripple.Draws, Is.False); + Assert.That(ripple.Skipped, Has.One.Matches(x => x.Contains("DISTORTION"))); + } + + // A continuous_rigid emitter draws its RigidData models, with or without a chain; an AnimFile plays, so it reports + // nothing. Its orientation and spin are read on all three axes. + [Test] + public void RigidEmitter_DrawsItsModels_TurningOnEveryAxis() + { + var properties = """"""; + var rat = Read(Emitter("rat", properties).Replace("continuous_mesh", "continuous_rigid"), "").Single(); + Assert.Multiple(() => + { + Assert.That(rat.Draws, Is.True); + Assert.That(rat.Rigid.Select(x => x.ModelFile), Is.EqualTo(new[] { "vfx/models/rat.wsmodel" })); + Assert.That((rat.TurnXy, rat.SpinXy, rat.SpinSignXy), Is.EqualTo(((new Vector2(10, 20), new Vector2(40, 50)), (new Vector2(1, 2), new Vector2(4, 5)), new Vector2(-1, 1)))); + Assert.That(rat.Skipped, Has.None.Matches(x => x.Contains("animations/rat.anim")), "the animation plays"); + }); + } + + // SpawnInheritedDirectionBlend is read, so it reports nothing. Wind is not simulated, so WindVarianceFactor says so on its + // own (453 vanilla emitters set it with no EffectedByWindFactor). ref_spawn_speed, on the element of 11 flamethrower + // and acid effects, is not read, and says so; -1, on the other 7,459, is unset. + [Test] + public void DirectionBlendIsRead_AndWindVarianceAndRefSpawnSpeedSaySo() + { + var properties = """"""; + var chain = SmokeChain.Replace("\"smoke\"", "\"e\""); + var flame = ReadFile(Xml(emitters: Emitter("e", properties), chains: chain, root: " ref_spawn_speed=\"30.00\"")); + var plain = ReadFile(Xml(emitters: Emitter("e"), chains: chain, root: " ref_spawn_speed=\"-1.00\"")); + Assert.Multiple(() => + { + Assert.That(flame.InheritedDirectionBlend, Is.EqualTo(0.5f)); + Assert.That(flame.Skipped, Has.None.Contains("SpawnInheritedDirectionBlend")); + Assert.That(flame.Skipped, Has.One.Contains("wind")); + Assert.That(flame.Skipped, Has.One.Contains("ref_spawn_speed 30")); + Assert.That(plain.Skipped, Has.None.Contains("ref_spawn_speed")); + }); + } + + static EmitterSettings ReadFile(byte[] file) => + EmitterSettings.From(new VfxResolver(Pack(("fx.xml", file))).Resolve("fx", new Random(1)).Emitters.Single()); + + // A DISTORTION chain names only a normal map; that is what it draws with. + [Test] + public void DistortionStack_DrawsWithItsNormalMap() + { + var ripple = Read(Emitter("ripple"), SmokeChain.Replace("\"smoke\"", "\"ripple\"").Replace("""path="vfx\textures\smoke_dense_loop_diffuse.dds"/>""", """path=""/>"""), + """""").Single(); + Assert.That((ripple.Draws, ripple.Distortion, ripple.Texture), Is.EqualTo((true, true, @"vfx\textures\shockwave_normal.dds"))); + } + + // VectorFieldStrId names a volume in the packs: loaded, the field is simulated; missing, the preview says which. + [Test] + public void VectorField_IsLoaded_OrSaysWhyNot() + { + var properties = """x_min,0,0;0.0,4.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,4.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1"""; + var pack = Pack(("fx.xml", Xml(emitters: Emitter("smoke", properties), chains: SmokeChain)), ("vectorfields/swirl.dds", Field(2, 2, 2, (_, _, _) => Vector3.UnitY))); + var loaded = new VfxResolver(pack).Resolve("fx", new Random(1)).Emitters.Select(EmitterSettings.From).Single(); + var missing = Read(Emitter("smoke", properties), SmokeChain).Single(); + Assert.Multiple(() => + { + Assert.That((loaded.Field?.Sample(Vector3.Zero), loaded.FieldSize?.Evaluate(0, 0, 0)), Is.EqualTo(((Vector3?)Vector3.UnitY, (float?)4))); + Assert.That(loaded.Skipped, Has.None.Matches(x => x.Contains("vector field"))); + Assert.That((missing.Draws, missing.Field), Is.EqualTo((true, (VfxVectorField?)null))); + Assert.That(missing.Skipped, Has.One.Matches(x => x.Contains("vfx/vectorfields/swirl.dds"))); + }); + } + + // DEFERRED_DYNAMIC_LIGHT: the particle is a light (249 of the 357 chains carry no ps_texture), drawn as the pool it + // casts on the ground. It lights the models too (RenderEngineComponent hands it to their shaders), so nothing is reported. + [Test] + public void DynamicLightStack_DrawsAsAPoolOnTheGround() + { + var light = Read(Emitter("glow"), """""", + """""").Single(); + Assert.That((light.Draws, light.Light, light.Decal, light.Texture), Is.EqualTo((true, true, true, (string?)null))); + Assert.That(light.Skipped, Has.None.Matches(x => x.Contains("lights only the ground"))); + } + + [TestCase("DEFERRED_DECAL")] + [TestCase("DEFERRED_DECAL_EMISSIVE")] + [TestCase("SEA_OVERLAY")] + public void GroundStacks_AreDrawnAsDecals(string view) + { + var scorch = Read(Emitter("smoke"), SmokeChain, + $"""""").Single(); + Assert.That((scorch.Draws, scorch.Decal), Is.EqualTo((true, true))); + Assert.That(Read(Emitter("smoke"), SmokeChain).Single().Decal, Is.False); + } + + [TestCase("Vertically-oriented", QuadOrientation.Vertical)] + [TestCase("Camera-facing velocity-aligned", QuadOrientation.VelocityAxis)] + [TestCase("Particle only rotating around local Y (horizontal)", QuadOrientation.LocalHorizontal)] + [TestCase("Particle only rotating around local Y (vertical)", QuadOrientation.LocalVertical)] + public void RareOrientationModes_AreRead(string mode, QuadOrientation expected) + { + var sparks = Read(Emitter("sparks"), SparksChain.Replace("Camera-aligned with stretching factor", mode)).Single(); + Assert.That((sparks.Orientation, sparks.Skipped.Any(x => x.Contains("orientation_mode"))), Is.EqualTo((expected, false))); + } + + // vs_translation's restriction, from CA's fx/particle_vfx_library.hlsl: "CameraFacing=0,Vertical,Horizontal,3D". 948 + // vanilla chains set it off 0, and every one of them drew camera-facing. + [TestCase("0", QuadOrientation.CameraFacing)] + [TestCase("1", QuadOrientation.Upright)] + [TestCase("2", QuadOrientation.Flat)] + [TestCase("3", QuadOrientation.Object3D)] + public void Restriction_IsRead(string restriction, QuadOrientation expected) + { + const string pivot = """"""; + var smoke = Read(Emitter("smoke"), SmokeChain.Replace(pivot, pivot + $"""""")).Single(); + Assert.That(smoke.Orientation, Is.EqualTo(expected)); + } + + [Test] + public void OverlayColour_IsRead() + { + var overlaid = SmokeChain.Replace("""value="true"/>""", """value="true"/>"""); + Assert.That((Read(Emitter("smoke"), overlaid).Single().Overlay, Read(Emitter("smoke"), SmokeChain).Single().Overlay), Is.EqualTo((true, false))); + } + + [Test] + public void FlipbookRows_AreRead() + { + var rows = SmokeChain.Replace("""""", """""" + + """"""); + var (read, plain) = (Read(Emitter("smoke"), rows).Single(), Read(Emitter("smoke"), SmokeChain).Single()); + Assert.That((read.RowRestricted, read.Row, read.RandomRow, plain.RowRestricted, plain.Row, plain.RandomRow), Is.EqualTo((true, 2, true, false, 0, false))); + } + + [Test] + public void FlipbookRandomCell_IsRead() + { + var random = SmokeChain.Replace("""""", """"""); + Assert.That((Read(Emitter("smoke"), random).Single().RandomCell, Read(Emitter("smoke"), SmokeChain).Single().RandomCell), Is.EqualTo((true, false))); + } + + [Test] + public void AlphaOffsets_AreRead() + { + var offsets = SmokeChain.Replace("""""", """""" + + """""" + + """"""); + var (read, plain) = (Read(Emitter("smoke"), offsets).Single(), Read(Emitter("smoke"), SmokeChain).Single()); + Assert.That((read.AlphaStartOffset, read.AlphaEndOffset, plain.AlphaStartOffset, plain.AlphaEndOffset), + Is.EqualTo((new Vector2(0.1f, 0.3f), new Vector2(0.7f, 0.8f), Vector2.Zero, Vector2.Zero))); + } + + [TestCase("RELATIVE_TO_TERRAIN_NORMAL", true)] + [TestCase("DISABLED", false)] + public void RelativeSpawnDirection_IsRead(string value, bool onTerrain) + { + var emitter = Emitter("smoke", $""""""); + Assert.That((Read(emitter, SmokeChain).Single().DirectionOnTerrain, Read(Emitter("smoke"), SmokeChain).Single().DirectionOnTerrain), Is.EqualTo((onTerrain, false))); + } + + [Test] + public void VectorFieldLoopFlags_AreRead() + { + var looped = Read(Emitter("smoke", """"""), SmokeChain).Single(); + var plain = Read(Emitter("smoke"), SmokeChain).Single(); + Assert.That((looped.LoopFieldSize, looped.LoopFieldPosition, looped.LoopFieldOrientation, plain.LoopFieldSize, plain.LoopFieldPosition, plain.LoopFieldOrientation), + Is.EqualTo((true, true, true, false, false, false))); + } + + [Test] + public void MaximumVelocity_IsRead() => + Assert.That((Read(Emitter("smoke", """"""), SmokeChain).Single().MaxSpeed, + Read(Emitter("smoke"), SmokeChain).Single().MaxSpeed), Is.EqualTo((1.5f, 0f))); + + // ParticleRelativeSpawnOrientation is read on a quad emitter too: a 3D quad stands on the ground as a rigid model does. + [Test] + public void RelativeSpawnOrientation_IsReadOnAQuadEmitter() + { + var onGround = Read(Emitter("smoke", """"""), SmokeChain).Single(); + Assert.That((onGround.RigidOnTerrain, Read(Emitter("smoke"), SmokeChain).Single().RigidOnTerrain), Is.EqualTo((true, false))); + } + + [Test] + public void UnknownOrientationMode_DrawsCameraFacing_AndSaysSo() + { + var sparks = Read(Emitter("sparks"), SparksChain.Replace("Camera-aligned with stretching factor", "Made up")).Single(); + Assert.That((sparks.Draws, sparks.Orientation), Is.EqualTo((true, QuadOrientation.CameraFacing))); + Assert.That(sparks.Skipped, Has.One.Matches(x => x.Contains("Made up"))); + } + + [Test] + public void FlipUv_IsRead() + { + var flip = """"""; + var sparks = Read(Emitter("sparks"), SparksChain.Replace("", flip + "")).Single(); + Assert.That((sparks.FlipU, sparks.FlipV, sparks.UvTurn), Is.EqualTo((0.5f, 1f, -1))); + } + + [Test] + public void RenderSizeLimiter_IsRead() + { + var limiter = """"""; + Assert.That(Read(Emitter("sparks"), SparksChain.Replace("", limiter + "")).Single().MinPixels, Is.EqualTo(3f)); + } + + [Test] + public void AlphaCrush_AndGradient_AreRead() + { + var crush = """progress_min,8388608,0;0.0,0.25,linear,linear,-0.05,0.0,0.05,0.0,0:progress,0;0,0"""; + var gradient = """r_min,16711680,0;0.0,0.5,linear,linear,-0.05,0.0,0.05,0.0,0:r,0;0,0|g,0;0,0|b,0;0,0alpha_min,8388608,0;0.0,1.0,linear,linear,-0.05,0.0,0.05,0.0,0:alpha,0;0,0"""; + var e = Read(Emitter("sparks"), SparksChain.Replace("", crush + gradient + "")).Single(); + Assert.Multiple(() => + { + Assert.That((e.CrushSharp, e.AlphaCrush?.Evaluate(0, 0.5f, 0)), Is.EqualTo((true, (float?)0.25f))); + Assert.That((e.Gradient?.Evaluate(1, 0.5f, 0), e.GradientAlpha?.Evaluate(0, 0.5f, 0), e.GradientMapsAlpha), Is.EqualTo(((float?)0.5f, (float?)1f, true))); + }); + var plain = Read(Emitter("sparks"), SparksChain).Single(); + Assert.That(new object?[] { plain.AlphaCrush, plain.Gradient }, Is.All.Null); + } + + [Test] + public void CellBlend_IsRead() + { + var blended = SmokeChain.Replace("", ""); + Assert.That(Read(Emitter("smoke"), blended).Single().CellBlend, Is.True); + Assert.That(Read(Emitter("smoke"), SmokeChain).Single().CellBlend, Is.False); + } + + [Test] + public void SoftEdge_IsRead() + { + var soft = """"""; + var e = Read(Emitter("sparks"), SparksChain.Replace("", soft + "")).Single(); + Assert.That((e.SoftEdge, e.SoftStrength), Is.EqualTo((1.5f, 2f))); + Assert.That(Read(Emitter("sparks"), SparksChain).Single().SoftEdge, Is.Zero, "no ps_soft_edge: a hard edge"); + } + + [Test] + public void Emissive_IsRead() + { + var emissive = """"""; + Assert.That(Read(Emitter("sparks"), SparksChain.Replace("", emissive + "")).Single().EmissiveMul, Is.EqualTo(21f)); + Assert.That(Read(Emitter("sparks"), SparksChain).Single().EmissiveMul, Is.EqualTo(1f), "no ps_emissive: the colour as it is"); + } + + [Test] + public void CullDistances_AreRead() + { + var e = Read(Emitter("sparks", """"""), SparksChain).Single(); + Assert.That((e.EmitterCull, e.ParticleCull), Is.EqualTo((new Vector4(0, 0, 700, 800), new Vector4(2, 5, 400, 450)))); + } + + [Test] + public void SkipParticlePositionUpdate_IsRead() + { + var head = Read(Emitter("sparks", """"""), SparksChain).Single(); + Assert.That((head.SkipPositionUpdate, Read(Emitter("sparks"), SparksChain).Single().SkipPositionUpdate), Is.EqualTo((true, false))); + } + + [Test] + public void SpawnSpacing_IsRead() + { + var trail = Read(Emitter("sparks", """"""), SparksChain).Single(); + Assert.That((trail.SpawnSpacing, Read(Emitter("sparks"), SparksChain).Single().SpawnSpacing), Is.EqualTo((0.5f, 0f))); + } + + [Test] + public void TrailMode_IsReadFromTheRenderStack() + { + var trail = Read(Emitter("sparks"), SparksChain, """""").Single(); + Assert.That((trail.Trail, Read(Emitter("sparks"), SparksChain).Single().Trail), Is.EqualTo((true, false))); + } + + [Test] + public void EmitterWithoutAChain_IsNotDrawn() => Assert.That(Read(Emitter("lonely"), "").Single().Draws, Is.False); + + [Test] + public void UnknownFragment_IsNamed_AndTheRestStillDraws() + { + var chain = SmokeChain.Replace("", ""); + var smoke = Read(Emitter("smoke"), chain).Single(); + Assert.That(smoke.Draws, Is.True); + Assert.That(smoke.Skipped, Has.One.Matches(x => x.Contains("ps_made_up"))); + } + + // 8,908 of 11,722 vanilla continuous_mesh emitters omit SpawnAngle; where present it is an azimuth range + // (0..360 in 802, -180..180 in 366). Read as 0,0, every emitter without one sprayed into +X alone. + [Test] + public void AbsentSpawnAngle_IsEveryAzimuth_AndAPresentOneIsRead() + { + Assert.That(Read(Emitter("smoke"), SmokeChain).Single().SpawnAngle, Is.EqualTo(new Vector2(0, 360))); + var narrow = Read(Emitter("smoke", """"""), SmokeChain).Single(); + Assert.That(narrow.SpawnAngle, Is.EqualTo(new Vector2(-90, 90))); + } + + // SmokeChain with its ps_texture modifier swapped for another. + static string SmokeWithout(string replacement) + { + var start = SmokeChain.IndexOf(""); + var end = SmokeChain.IndexOf("", start) + "".Length; + return SmokeChain[..start] + replacement + SmokeChain[end..]; + } + + // 231 vanilla chains (wh3_blood_effects among them) draw through a material and carry no ps_texture. + // Drawn anyway, each particle was a flat quad in the colour curve's tint. + [Test] + public void CustomShaderChain_IsNotDrawn_AndNamesItsMaterial() + { + var blood = Read(Emitter("smoke"), SmokeWithout("""""")).Single(); + Assert.That(blood.Draws, Is.False); + Assert.That(blood.Skipped, Has.One.Matches(x => x.Contains("ps_custom_shader") && x.Contains("wh3_blood_spurt_01"))); + } + + // A custom-shader material's colour texture (a slot named for a diffuse or base colour) stands in for its shader. One + // with none, as the fire's alpha masks, is not drawn and says so. + [TestCase("t_xml_particle_diffuse_2048_only", true)] + [TestCase("t_xml_alpha_1", false)] + public void CustomShaderChain_DrawsItsMaterialsColourTexture(string slot, bool drawn) + { + var material = $"t_xml_dithervfx/textures/dither.dds{slot}vfx/Textures/blood_base.dds"; + var chain = SmokeWithout(""""""); + var pack = Pack(("fx.xml", Xml(emitters: Emitter("smoke"), chains: chain)), ("materials/wh3_blood_spurt_01.xml.material", System.Text.Encoding.UTF8.GetBytes(material))); + var blood = new VfxResolver(pack).Resolve("fx", new Random(1)).Emitters.Select(EmitterSettings.From).Single(); + Assert.That((blood.Draws, blood.Texture), Is.EqualTo((drawn, drawn ? "vfx/Textures/blood_base.dds" : null))); + Assert.That(blood.Skipped, Has.One.Matches(x => x.Contains("ps_custom_shader") && x.Contains("wh3_blood_spurt_01"))); + } + + // The five vanilla drawn chains with no ps_texture all belong to emitters named "sound": they carry a sound and + // show nothing, where a white quad each was drawn. + [Test] + public void ChainWithoutATexture_IsNotDrawn_AndSaysSo() + { + var plain = Read(Emitter("smoke"), SmokeWithout("")).Single(); + Assert.That(plain.Draws, Is.False); + Assert.That(plain.Skipped, Has.One.Matches(x => x.Contains("ps_texture"))); + } + + // A wind simulation stack has no ps_texture either, and is not drawn at all: it says why once, not also that its chain + // lacks a texture. + [Test] + public void UndrawnChainWithoutATexture_SaysOnlyWhyItIsNotDrawn() + { + var wind = Read(Emitter("smoke"), SmokeWithout(""), + """""").Single(); + Assert.That(wind.Draws, Is.False); + Assert.That(wind.Skipped, Has.None.Matches(x => x.Contains("ps_texture"))); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/ParticleRenderItemTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/ParticleRenderItemTests.cs new file mode 100644 index 000000000..3662b2f11 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/ParticleRenderItemTests.cs @@ -0,0 +1,1134 @@ +using GameWorld.Core.Components.Rendering; +using GameWorld.Core.Rendering; +using GameWorld.Core.SceneNodes; +using GameWorld.Core.Vfx; +using System.IO; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using Shared.GameFormats.Vfx; +using Test.TestingUtility.Shared; + +namespace GameWorld.Core.Test.Vfx +{ + [Apartment(ApartmentState.STA)] + public class ParticleRenderItemTests + { + GraphicsDeviceServiceMock _service; + GraphicsDevice _device; + Texture2D _white; + ContentManager _content; + // The content manager owns it, and caches it. + Effect Shader => _content.Load("Shaders/Particle"); + + // Looking down -Z at the origin from 5 m; a 10 m quad there fills the view. + static readonly CommonShaderParameters View = new( + Matrix.CreateLookAt(new Vector3(0, 0, 5), Vector3.Zero, Vector3.Up), + Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, 1, 0.1f, 100), + new Vector3(0, 0, 5), Vector3.Zero, 0, 0, 0, 1, [], Vector3.One); + + static readonly ParticleState RedHalfAlpha = new(Vector3.Zero, Vector3.Zero, new Vector2(10, 10), 0, new Vector3(1, 0, 0), 0.5f, 0, 0); + + [OneTimeSetUp] + public void Setup() + { + _service = new GraphicsDeviceServiceMock(); + _device = _service.GraphicsDevice; + _white = new Texture2D(_device, 1, 1); + _white.SetData(new[] { Color.White }); + _content = Content(_service); + } + + // Shaders/Particle comes out of this test's own copy of the content. + internal static ContentManager Content(IGraphicsDeviceService service) + { + var services = new GameServiceContainer(); + services.AddService(service); + return new ContentManager(services, Path.Combine(TestContext.CurrentContext.TestDirectory, "Content")); + } + + [OneTimeTearDown] + public void TearDown() + { + _white.Dispose(); + _content.Dispose(); + _service.Release(); + } + + // Draws onto mid grey, or the background given, and returns one pixel from the middle. + Vector4 Render(Action draw, Color? background = null) + { + // Preserved as the app's are: a distortion copies the target and draws on, and a discarded one comes back cleared. + using var target = new RenderTarget2D(_device, 4, 4, false, SurfaceFormat.Color, DepthFormat.None, 0, RenderTargetUsage.PreserveContents); + _device.SetRenderTarget(target); + _device.Clear(background ?? new Color(128, 128, 128, 255)); + _device.DepthStencilState = DepthStencilState.None; + _device.RasterizerState = RasterizerState.CullNone; + draw(); + _device.SetRenderTarget(null); + var pixels = new Color[16]; + target.GetData(pixels); + return pixels[5].ToVector4(); + } + + [TestCase(true, 0f, 0.751f, 0.251f, Description = "alpha: red at half over grey")] + [TestCase(true, 1f, 1.0f, 0.502f, Description = "additive: red added to grey")] + [TestCase(true, 0.5f, 0.876f, 0.376f, Description = "half additive")] + [TestCase(false, 0f, 0.751f, 0.251f, Description = "straight-alpha texture, alpha blend")] + [TestCase(false, 1f, 1.0f, 0.502f, Description = "straight-alpha texture, additive")] + public void Blend_OverAKnownBackground(bool premultiplied, float additiveMul, float red, float greenAndBlue) + { + var settings = new EmitterSettings { Premultiplied = premultiplied, AdditiveMul = additiveMul }; + var effect = Shader; + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, effect, View, Matrix.Identity, camera, settings, _white, [RedHalfAlpha])); + Assert.Multiple(() => + { + Assert.That(pixel.X, Is.EqualTo(red).Within(0.02)); + Assert.That(pixel.Y, Is.EqualTo(greenAndBlue).Within(0.02)); + Assert.That(pixel.Z, Is.EqualTo(greenAndBlue).Within(0.02)); + }); + } + + // ps_texture's overlay_colour: CA's process_texture_colour overlays the colour on the texture instead of multiplying it in, + // and the alpha still multiplies. A mid-grey texel (0.216 linear, under the overlay's half) under a (1, 0.5, 0) colour: + // 2 x 0.216 x 1 = 0.432 red, 0.216 green, none blue, back to the screen's gamma (0.690, 0.502, 0). The same blend in + // gamma, or with the colour as the base, gives full red; a multiply gives (0.502, 0.251, 0). At half alpha over grey + // the premultiplied colour is that half covering the background's half. An additive_blend_mul of 2 doubles the overlaid + // colour after the blend and hides none of the background: 2 x 0.25 x (0.690, 0.502, 0) over all of the grey. + [TestCase(true, 1f, 0f, 0.690f, 0.502f, 0f)] + [TestCase(false, 1f, 0f, 0.502f, 0.251f, 0f)] + [TestCase(true, 0.5f, 0f, 0.596f, 0.502f, 0.251f)] + [TestCase(true, 0.25f, 2f, 0.847f, 0.753f, 0.502f)] + public void OverlayColour_OverlaysTheColourOnTheTexture(bool overlay, float alpha, float additive, float red, float green, float blue) + { + using var grey = new Texture2D(_device, 1, 1); + grey.SetData(new[] { new Color(128, 128, 128, 255) }); + var particle = RedHalfAlpha with { Colour = new Vector3(1, 0.5f, 0), Alpha = alpha }; + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, + new EmitterSettings { Overlay = overlay, AdditiveMul = additive }, grey, [particle])); + Assert.Multiple(() => + { + Assert.That(pixel.X, Is.EqualTo(red).Within(0.02)); + Assert.That(pixel.Y, Is.EqualTo(green).Within(0.02)); + Assert.That(pixel.Z, Is.EqualTo(blue).Within(0.02)); + }); + } + + // An overlay chain feeds the glow pass as any chain does, by what it draws (GlowPass_IsCAsBrightPass), and what it draws is + // its overlaid colour held to 1, as CA holds it to the texel's alpha, times its additive_blend_mul. So no tint makes it + // glow, not even (40, 0, 0) over a mid-grey texel, which overlays to 17; a white core in an orange tint glows white at + // additive_blend_mul 8 (luminance 8), not at 0, and lit, not at all. + [TestCase(255, 1f, 0.5f, 0f, false, false)] + [TestCase(255, 1f, 0.5f, 0f, true, false)] + [TestCase(128, 40f, 0f, 0f, false, false)] + [TestCase(255, 1f, 0.5f, 8f, false, true)] + public void OverlayColour_GlowsOnlyByItsBrightness(int texel, float red, float green, float additive, bool lit, bool glows) + { + using var texture = new Texture2D(_device, 1, 1); + texture.SetData(new[] { new Color(texel, texel, texel, 255) }); + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var core = RedHalfAlpha with { Colour = new Vector3(red, green, 0), Alpha = 1 }; + var settings = new EmitterSettings { Overlay = true, AdditiveMul = additive, Lighting = lit ? new ParticleLighting(0.8f, 0, 0.4f, 0, 0.8f) : null }; + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, settings, texture, [core], glow: true), Color.Black); + Assert.That(new[] { pixel.X, pixel.Y, pixel.Z }, Is.All.EqualTo(glows ? 1 : 0).Within(0.02)); + } + + // 122 of vanilla's 448 overlay chains are lit: the overlaid colour is the albedo, under the sky's 0.261 from a white ambient + // cube. The grey texel decodes to 0.216; overlaid with (1, 0.5, 0) that is (0.432, 0.216, 0). + [Test] + public void OverlayColour_IsTheAlbedoOfALitChain() + { + using var sky = new TextureCube(_device, 1, false, SurfaceFormat.Color); + foreach (var face in Enum.GetValues()) + sky.SetData(face, new[] { Color.White }); + using var grey = new Texture2D(_device, 1, 1); + grey.SetData(new[] { new Color(128, 128, 128, 255) }); + var effect = Shader; + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var quad = new ParticleState(Vector3.Zero, Vector3.Zero, new Vector2(10, 10), 0, new Vector3(1, 0.5f, 0), 1, 0, 0); + effect.Parameters["AmbientCube"].SetValue(sky); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, effect, View, Matrix.Identity, camera, + new EmitterSettings { Lighting = new ParticleLighting(0, 0, 0.4f, 0, 0.6f), Overlay = true }, grey, [quad])); + effect.Parameters["AmbientCube"].SetValue((TextureCube?)null); + var texel = MathF.Pow((128 / 255f + 0.055f) / 1.055f, 2.4f); + Assert.Multiple(() => + { + Assert.That(pixel.X, Is.EqualTo(Encode(Uncharted2(2 * texel * 0.261f))).Within(0.01)); + Assert.That(pixel.Y, Is.EqualTo(Encode(Uncharted2(texel * 0.261f))).Within(0.01)); + }); + } + + // The app draws its scene onto nothing and lays it over the background by its alpha, so over nothing a particle's + // alpha is what it hides of the background: half for one laid over at half, none for light it adds. + [TestCase(false, 0f, 0.5f, Description = "straight-alpha texture, alpha blend")] + [TestCase(false, 1f, 0f, Description = "straight-alpha texture, additive")] + [TestCase(true, 0f, 0.5f, Description = "premultiplied, alpha blend")] + [TestCase(true, 1f, 0f, Description = "premultiplied, additive")] + public void OverNothing_AParticleHidesWhatItCovers(bool premultiplied, float additiveMul, float alpha) + { + var settings = new EmitterSettings { Premultiplied = premultiplied, AdditiveMul = additiveMul }; + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, settings, _white, [RedHalfAlpha]), Color.Transparent); + Assert.That(pixel.W, Is.EqualTo(alpha).Within(0.02)); + } + + // ps_texture reads a texture's alpha as sRGB, as the hardware reads its colour off a BC7_UNORM_SRGB file: CA's ps_main + // linearises it before anything else uses it. A white texel at alpha 128 covers 21.6 % of black, not half. + [TestCase(true)] + [TestCase(false)] + public void TextureAlpha_IsReadAsSrgb(bool premultiplied) + { + using var texture = new Texture2D(_device, 1, 1); + texture.SetData(new[] { premultiplied ? new Color(128, 128, 128, 128) : new Color(255, 255, 255, 128) }); + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var white = new ParticleState(Vector3.Zero, Vector3.Zero, new Vector2(10, 10), 0, Vector3.One, 1, 0, 0); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, + new EmitterSettings { Premultiplied = premultiplied }, texture, [white]), Color.Black); + Assert.That(pixel.X, Is.EqualTo(0.216).Within(0.01)); + } + + // ps_lighting on a chain blended by alpha, against CA's ps_main transcribed below: smoke_heavy's settings lit from the + // front, vanilla's defaults lit from behind, no particles to scatter (no sun at all), half emissive, and a scene light at + // double strength. No ambient cube is bound, so the sky adds nothing. The 10 m quad faces the camera. + [TestCase(0.8f, 0f, 0.4f, 0f, 0.8f, 0.04f, 0f, 1f)] + [TestCase(0.1f, 0.5f, 0.4f, 0f, 0.6f, 0.5f, MathHelper.Pi, 1f)] + [TestCase(0f, 0f, 0.4f, 0f, 0.6f, 0.5f, 0f, 1f)] + [TestCase(0.8f, 0f, 0.4f, 0.5f, 0.8f, 0.2f, 0f, 1f)] + [TestCase(0.8f, 0f, 0.4f, 0f, 0.8f, 0.04f, 0f, 2f)] + public void Lighting_LightsASmokeQuadAsCaDoes(float small, float large, float directionality, float emissive, float averageSize, float colour, float turn, float intensity) + { + var lighting = new ParticleLighting(small, large, directionality, emissive, averageSize); + var view = View with { DirLightRotationRadians_Y = turn, LightIntensityMult = intensity }; + var camera = ParticleRenderItem.CameraAxes.From(view, Matrix.Identity); + var quad = new ParticleState(Vector3.Zero, Vector3.Zero, new Vector2(10, 10), 0, new Vector3(colour), 1, 0, 0); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, view, Matrix.Identity, camera, new EmitterSettings { Lighting = lighting }, _white, [quad])); + + var toSun = Vector3.Normalize(Vector3.TransformNormal(Vector3.Normalize(new Vector3(0.1f, 0.1f, 1)), Matrix.CreateRotationY(turn))); + var sun = 20000 * 3 * intensity * MathF.Pow(MathF.Sin(MathHelper.ToRadians(0.5f)), 2); + var stored = new Color(new Vector4(colour, colour, colour, 1)).R / 255f; + var expected = Encode(Math.Clamp(Uncharted2(stored * CaLight(lighting, 10, Vector3.UnitZ, toSun, Vector3.UnitZ, sun)), 0, 1)); + Assert.That(pixel.X, Is.EqualTo(expected).Within(0.01), $"lit {pixel.X}, unlit {colour}"); + } + + // The sky's light, through the ambient cube the models use, at its 0.261: no sun here, as no particles scatter it. + [Test] + public void Lighting_TheSkyLightsASmokeQuad() + { + using var sky = new TextureCube(_device, 1, false, SurfaceFormat.Color); + foreach (var face in Enum.GetValues()) + sky.SetData(face, new[] { new Color(128, 128, 128, 255) }); + var effect = Shader; + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var quad = new ParticleState(Vector3.Zero, Vector3.Zero, new Vector2(10, 10), 0, Vector3.One, 1, 0, 0); + effect.Parameters["AmbientCube"].SetValue(sky); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, effect, View, Matrix.Identity, camera, + new EmitterSettings { Lighting = new ParticleLighting(0, 0, 0.4f, 0, 0.6f) }, _white, [quad])); + effect.Parameters["AmbientCube"].SetValue((TextureCube?)null); + Assert.That(pixel.X, Is.EqualTo(Encode(Uncharted2(128 / 255f * 0.261f))).Within(0.01)); + } + + // A lit particle's colour is light, as a model's is, and blooms no more than a model's does. + [Test] + public void Lighting_ALitQuadAddsNothingToTheGlowPass() + { + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var quad = new ParticleState(Vector3.Zero, Vector3.Zero, new Vector2(10, 10), 0, new Vector3(40), 1, 0, 0); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, + new EmitterSettings { Lighting = new ParticleLighting(0.8f, 0, 0.4f, 10, 0.8f) }, _white, [quad], glow: true), Color.Black); + Assert.That(pixel.X, Is.EqualTo(0).Within(0.01)); + } + + // ...but it still hides the glow of what lies behind it, as unlit smoke does: a fire's glow under a lit puff of alpha 1 + // is gone from the glow pass. + [Test] + public void Lighting_ALitQuadHidesTheGlowBehindIt() + { + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var fire = new ParticleState(Vector3.Zero, Vector3.Zero, new Vector2(10, 10), 0, new Vector3(40, 0, 0), 1, 0, 0); + var puff = fire with { Colour = new Vector3(4) }; + var pixel = Render(() => + { + ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, new EmitterSettings { AdditiveMul = 1 }, _white, [fire], glow: true); + ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, + new EmitterSettings { Lighting = new ParticleLighting(0.8f, 0, 0.4f, 0, 0.8f) }, _white, [puff], glow: true); + }, Color.Black); + Assert.That(pixel.X, Is.EqualTo(0).Within(0.01)); + } + + // ps_normal shapes that light. CA's ps_main reads a DXT5nm texel, x in alpha and y in green, each half a step up, y down + // the texture. The sun high and the texel tilted up; the sun to the right and the texel tilted right; and the same quad + // mirrored left to right, which tilts it left, away from the sun. + [TestCase(-MathHelper.PiOver2, 0f, 128, 51, false)] + [TestCase(0f, MathHelper.PiOver2, 204, 128, false)] + [TestCase(0f, MathHelper.PiOver2, 204, 128, true)] + public void NormalMap_ShapesTheLight(float pitch, float turn, int alpha, int green, bool mirrored) + { + var lighting = new ParticleLighting(0.8f, 0, 0.4f, 0, 0.8f); + var view = View with { DirLightRotationRadians_X = pitch, DirLightRotationRadians_Y = turn }; + using var normals = new Texture2D(_device, 1, 1); + normals.SetData(new[] { new Color(0, green, 0, alpha) }); + var camera = ParticleRenderItem.CameraAxes.From(view, Matrix.Identity); + var quad = new ParticleState(Vector3.Zero, Vector3.Zero, new Vector2(10, 10), 0, new Vector3(128 / 255f), 1, 0, 0, FlipU: mirrored); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, view, Matrix.Identity, camera, + new EmitterSettings { Lighting = lighting }, _white, [quad], normalMap: normals)); + + var (x, y) = (2 * (alpha / 255f + 0.001961f) - 1, 2 * (green / 255f + 0.001961f) - 1); + var tangent = Vector3.Normalize(new Vector3(x, y, MathF.Sqrt(Math.Max(0, 1 - x * x - y * y)))); + // Across the quad is u, which the mirror turns round; down it is v. + var normal = Vector3.Normalize(tangent.X * (mirrored ? -Vector3.UnitX : Vector3.UnitX) - tangent.Y * Vector3.UnitY + tangent.Z * Vector3.UnitZ); + var toSun = Vector3.Normalize(Vector3.TransformNormal(Vector3.Normalize(new Vector3(0.1f, 0.1f, 1)), Matrix.CreateRotationY(turn) * Matrix.CreateRotationX(pitch))); + var sun = 20000 * 3 * MathF.Pow(MathF.Sin(MathHelper.ToRadians(0.5f)), 2); + float Seen(Vector3 n) => Encode(Math.Clamp(Uncharted2(128 / 255f * CaLight(lighting, 10, n, toSun, Vector3.UnitZ, sun)), 0, 1)); + Assert.That(pixel.X, Is.EqualTo(Seen(normal)).Within(0.01), $"flat {Seen(Vector3.UnitZ)}"); + } + + // CA's ps_main lighting (fx/particle_vfx_main in shaders.pack, disassembled): sunlight scattered through the particle, + // Rayleigh's phase for small_particles and Henyey-Greenstein's, with (1 - g)^2 over it, for large_particles at + // g = directionality, weighted by each, over light let through, exp(-(small + large) x average_size x size x (1 - N.L) / 2); + // drawn toward emissive by its own amount. sun: the light a face square to the sun takes. c is the cosine between the + // way the sunlight travels and the camera's axis toward the camera. No particles is 0 / 0, which the GPU saturates to 0. + static float CaLight(ParticleLighting l, float size, Vector3 normal, Vector3 toSun, Vector3 toCamera, float sun) + { + var c = Vector3.Dot(-toSun, toCamera); + var sigma = l.Small + l.Large; + var through = MathF.Exp(-sigma * l.AverageSize * size * (1 - Vector3.Dot(normal, toSun)) * 0.5f); + var g = l.Directionality; + var rayleigh = 0.059683f * (1 + c * c); + var henyey = (1 - g) * (1 - g) * 0.079577f / MathF.Pow(1 + g * g - 2 * g * c, 1.5f); + var lit = ((l.Small * rayleigh + l.Large * henyey) / sigma) * (1 - through) + through; + lit = float.IsNaN(lit) ? 0 : Math.Clamp(lit, 0, 1); + return MathHelper.Lerp(lit * sun, l.Emissive, Math.Clamp(l.Emissive, 0, 1)); + } + + // Shaders/Pbr's tone map and sRGB encoding, through which a model's lit colour reaches the screen. + static float Uncharted2(float x) + { + static float F(float v) => (v * (0.15f * v + 0.05f) + 0.004f) / (v * (0.15f * v + 0.5f) + 0.06f) - 0.02f / 0.3f; + return F(x * 2) / F(11.2f); + } + + static float Encode(float x) => x <= 0.0031308f ? 12.92f * x : 1.055f * MathF.Pow(x, 1 / 2.4f) - 0.055f; + + // The ground light and a heat haze hide nothing over nothing: the light's unlit corners and a haze with no scene behind + // it blacked out the background. + [Test] + public void OverNothing_ALightOrAHazeHidesNothing() + { + var pool = Render(() => ParticleRenderItem.DrawGroundLight(_device, Shader, Above([new PointLight(new Vector3(0, 20, 0), 40, new Vector3(0.4f, 0, 0) * VfxConventions.LightShare)])), Color.Transparent); + var normal = new Texture2D(_device, 1, 1); + normal.SetData(new[] { new Color(255, 128, 255, 255) }); + using var cache = new VfxTextureCache((_, _) => normal); + using var item = new ParticleRenderItem(OneParticle("normal", distortion: true), cache, () => Shader); + var haze = Render(() => item.Draw(_device, View, RenderingTechnique.Normal), Color.Transparent); + Assert.That(new[] { pool.W, haze.W }, Is.All.EqualTo(0).Within(0.01)); + } + + // A colour of 4 lights a quarter-grey texel fully: a vertex colour alone stops at 1 and would leave it a quarter. + [Test] + public void HdrColour_IsNotClippedBeforeTheTexture() + { + using var quarter = new Texture2D(_device, 1, 1); + quarter.SetData(new[] { new Color(64, 64, 64, 255) }); + var effect = Shader; + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var bright = RedHalfAlpha with { Colour = new Vector3(4, 0, 0), Alpha = 1 }; + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, effect, View, Matrix.Identity, camera, new EmitterSettings(), quarter, [bright])); + Assert.That(pixel.X, Is.EqualTo(1).Within(0.02)); + } + + // The glow pass is CA's bloom bright pass (fx_hdr_tone_mapper's ps_high_pass_luminance_cutoff): what a particle adds, + // weighed by how far its luminance lies over bloom_threshold, 4, times bloom_particle_strength, 0.5. Here a grey added onto + // mid grey: 4.2 adds 4.2 x 0.2 x 0.5; 4, a chaos fire's brightness on a white texel, adds nothing, nor does 1.25. A + // straight-alpha particle adds its colour times its alpha: 7 at alpha 0.6 adds as 4.2 does, 6.5 at alpha 0.6 nothing. + [TestCase(4.2f, 1f, true, 0.922f)] + [TestCase(4f, 1f, true, 0.502f)] + [TestCase(1.25f, 1f, true, 0.502f)] + [TestCase(7f, 0.6f, false, 0.922f)] + [TestCase(6.5f, 0.6f, false, 0.502f)] + public void GlowPass_IsCAsBrightPass(float colour, float alpha, bool premultiplied, float red) + { + var effect = Shader; + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var particle = RedHalfAlpha with { Colour = new Vector3(colour), Alpha = alpha }; + var settings = new EmitterSettings { AdditiveMul = 1, Premultiplied = premultiplied }; + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, effect, View, Matrix.Identity, camera, settings, _white, [particle], glow: true)); + Assert.That(pixel.X, Is.EqualTo(red).Within(0.02)); + } + + // The app's emissive pass, which it blooms: an effect draws there too, a tint of 10 leaving white. + [Test] + public void TheEmissivePass_DrawsTheEffectsGlow() + { + using var item = new ParticleRenderItem(OneParticle(null, new Vector4(10, 10, 10, 1)), NoTextures(), () => Shader); + var pixel = Render(() => item.Draw(_device, View, RenderingTechnique.Emissive)); + Assert.That(new[] { pixel.X, pixel.Y, pixel.Z }, Is.All.EqualTo(1).Within(0.02)); + } + + // ps_soft_edge against the preview's ground, world y = 0: a 1 m flat quad 0.5 m up, seen from 10 m straight above, is + // 0.5 m of view ray from the ground, half of a 1 m soft_factor; below the ground it is hidden. A quad under a metre fades + // over less, by scale_strength: a 0.25 m one 0.1 m up is 0.4 of the way in at full strength and 0.1 at none. The effect's + // own y = 0 is not the ground: lifted 1 m by its matrix, a quad 0.5 m below its origin is 0.5 m up. + [Test] + public void SoftEdges_FadeIntoTheGround() + { + var looking = new ParticleRenderItem.CameraAxes(new Vector3(0, 10, 0), Vector3.Right, Vector3.Forward, Vector3.Down); + float Alpha(EmitterSettings s, float height, ParticleRenderItem.CameraAxes camera, float size = 1) => + ParticleRenderItem.Quads(s, camera, [new(new Vector3(0, height, 0), Vector3.Zero, new Vector2(size), 0, Vector3.One, 1, 0, 0)])[0].Color.A / 255f; + EmitterSettings Soft(float factor, float strength = 1) => new() { Premultiplied = false, Orientation = QuadOrientation.Horizontal, SoftEdge = factor, SoftStrength = strength }; + Assert.Multiple(() => + { + Assert.That(Alpha(Soft(0), 0.5f, looking), Is.EqualTo(1f), "no ps_soft_edge"); + Assert.That(Alpha(Soft(1), 0.5f, looking), Is.EqualTo(0.5f).Within(0.01)); + Assert.That(Alpha(Soft(1, 2), 0.5f, looking), Is.EqualTo(0.5f).Within(0.01), "no exponent"); + Assert.That(Alpha(Soft(1), 0.1f, looking, 0.25f), Is.EqualTo(0.4f).Within(0.01), "small, full strength"); + Assert.That(Alpha(Soft(1, 0), 0.1f, looking, 0.25f), Is.EqualTo(0.1f).Within(0.01), "small, no strength"); + Assert.That(Alpha(Soft(1), -0.5f, looking), Is.Zero, "under the ground"); + var lifted = new ParticleRenderItem.CameraAxes(new Vector3(0, 9, 0), Vector3.Right, Vector3.Forward, Vector3.Down, ToWorld: Matrix.CreateTranslation(0, 1, 0)); + Assert.That(Alpha(Soft(1), -0.5f, lifted), Is.EqualTo(0.5f).Within(0.01), "the ground is the world's"); + }); + } + + // ps_soft_edge against the solid scene, when the engine has drawn its depth: a 10 m white quad 5 m from the camera, well above + // the ground, with the scene 0.5 m behind it is half faded at a 1 m soft_factor, and with it 2 m behind not at all; the same + // through the app's orthographic view. A scale_strength of 0 (33 vanilla chains) is the full soft_factor. A 0.4 m quad, + // filling a 0.3 m view, with the scene 0.2 m behind it fades over 0.4 m at full strength, 0.7 at half and 1 at none. + [TestCase(5.5f, 1f, false, 10f, 0.5f)] + [TestCase(7f, 1f, false, 10f, 1f)] + [TestCase(5.5f, 1f, true, 10f, 0.5f)] + [TestCase(5.5f, 0f, false, 10f, 0.5f)] + [TestCase(5.2f, 1f, true, 0.4f, 0.5f)] + [TestCase(5.2f, 0.5f, true, 0.4f, 0.285f)] + [TestCase(5.2f, 0f, true, 0.4f, 0.2f)] + public void SoftEdges_FadeIntoTheSolidScene(float sceneDepth, float strength, bool orthographic, float size, float white) + { + using var depth = new Texture2D(_device, 4, 4, false, SurfaceFormat.Single); + depth.SetData(Enumerable.Repeat(sceneDepth, 16).ToArray()); + var level = Level(depth, orthographic, size < 1 ? 0.3f : 12); + var camera = ParticleRenderItem.CameraAxes.From(level, Matrix.Identity); + var quad = new ParticleState(new Vector3(0, 10, 0), Vector3.Zero, new Vector2(size), 0, Vector3.One, 1, 0, 0); + var settings = new EmitterSettings { Premultiplied = false, SoftEdge = 1, SoftStrength = strength }; + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, level, Matrix.Identity, camera, settings, _white, [quad]), Color.Black); + Assert.That(pixel.X, Is.EqualTo(white).Within(0.02)); + } + + // The glow pass draws onto a target holding only the emissive models' depth, so the scene's depth hides a particle's glow + // behind a plain model: a glowing quad 5 m away adds nothing behind a model 3 m away, and its glow in front of one at 7 m. + [TestCase(3f, false)] + [TestCase(7f, true)] + public void GlowPass_IsHiddenBehindTheSolidScene(float sceneDepth, bool glows) + { + using var depth = new Texture2D(_device, 4, 4, false, SurfaceFormat.Single); + depth.SetData(Enumerable.Repeat(sceneDepth, 16).ToArray()); + var level = Level(depth, false); + var camera = ParticleRenderItem.CameraAxes.From(level, Matrix.Identity); + var quad = new ParticleState(new Vector3(0, 10, 0), Vector3.Zero, new Vector2(10, 10), 0, new Vector3(40, 0, 0), 1, 0, 0); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, level, Matrix.Identity, camera, new EmitterSettings { Premultiplied = false }, _white, [quad], glow: true), Color.Black); + Assert.That(pixel.X > 0.5f, Is.EqualTo(glows), $"red {pixel.X}"); + } + + // Looking level along -Z at a point 10 m up from 5 m away, in perspective or the app's orthographic view (this wide), over a + // scene depth. + static CommonShaderParameters Level(Texture2D depth, bool orthographic, float wide = 12) => + new(Matrix.CreateLookAt(new Vector3(0, 10, 5), new Vector3(0, 10, 0), Vector3.Up), + orthographic ? Matrix.CreateOrthographic(wide, wide, 0.1f, 100) : Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, 1, 0.1f, 100), + new Vector3(0, 10, 5), new Vector3(0, 10, 0), 0, 0, 0, 1, [], Vector3.One) { SceneDepth = depth }; + + // cell_blend: half way from a red cell to a blue one draws both at half. + [TestCase(0f, 1f, 0f)] + [TestCase(0.5f, 0.5f, 0.5f)] + public void CellBlend_MixesTheCellWithTheNext(float blend, float red, float blue) + { + using var strip = new Texture2D(_device, 2, 1); + strip.SetData(new[] { new Color(255, 0, 0, 255), new Color(0, 0, 255, 255) }); + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var particle = RedHalfAlpha with { Colour = Vector3.One, Alpha = 1, Cell = 0, NextCell = 1, CellBlend = blend }; + var pixel = Render(() => + { + _device.SamplerStates[0] = SamplerState.PointClamp; + ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, new EmitterSettings { CellsX = 2 }, strip, [particle]); + }); + Assert.That((pixel.X, pixel.Z), Is.EqualTo((red, blue)).Using<(float, float)>((a, b) => Math.Abs(a.Item1 - b.Item1) < 0.02f && Math.Abs(a.Item2 - b.Item2) < 0.02f)); + } + + // ps_alpha_crush on a texel of alpha 0.75 (225, read as sRGB) over mid grey: a soft crush of 0.5 leaves half of it, a + // sharp one of 0.8 cuts it away and one of 0.5 leaves it whole. + [TestCase(false, 0f, 0.875f)] + [TestCase(false, 0.5f, 0.751f)] + [TestCase(true, 0.8f, 0.502f)] + [TestCase(true, 0.5f, 0.875f)] + public void AlphaCrush_ThinsTheTexture(bool sharp, float crush, float grey) + { + using var faint = new Texture2D(_device, 1, 1); + faint.SetData(new[] { new Color(225, 225, 225, 225) }); + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var particle = RedHalfAlpha with { Colour = Vector3.One, Alpha = 1, Crush = crush }; + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, new EmitterSettings { CrushSharp = sharp }, faint, [particle])); + Assert.That(pixel.X, Is.EqualTo(grey).Within(0.02)); + } + + // ps_gradient: a half-grey texel picks the colour half way along the strip, here red rising 0 to 1, no green, full blue. + [Test] + public void Gradient_ColoursTheTextureByItsValue() + { + using var grey = new Texture2D(_device, 1, 1); + grey.SetData(new[] { new Color(128, 128, 128, 255) }); + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var ramp = VfxCurve.Parse("up,1,0;0.0,0.0,linear,linear,0,0,0,0,0;1.0,1.0,linear,linear,0,0,0,0,0|none,1,0;0.0,0.0,linear,linear,0,0,0,0,0|full,1,0;0.0,1.0,linear,linear,0,0,0,0,0:r,0;0,0|g,0;1,1|b,0;2,2"); + var settings = new EmitterSettings { Gradient = ramp, GradientAlpha = VfxCurve.Constant(1) }; + var particle = RedHalfAlpha with { Colour = Vector3.One, Alpha = 1 }; + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, View, Matrix.Identity, camera, settings, grey, [particle])); + Assert.That(new[] { pixel.X, pixel.Y, pixel.Z }, Is.EqualTo(new[] { 0.5f, 0f, 1f }).Within(0.03)); + } + + // CA's vs_translation_velocity_v2 (fx/particle_vfx_library.hlsl). The camera at the origin looks down -Z at a particle 10 m + // off, moving 10 m/s at a stretch of 1 per m/s, capped at 3. Head-on is 5 degrees off the view, as a muzzle flash seen down + // its barrel is. + static readonly ParticleRenderItem.CameraAxes DownMinusZ = new(Vector3.Zero, Vector3.UnitX, Vector3.UnitY, -Vector3.UnitZ); + static readonly Vector3 HeadOn = new(10 * MathF.Sin(MathHelper.ToRadians(5)), 0, 10 * MathF.Cos(MathHelper.ToRadians(5))); + + static (Vector3 Across, Vector3 Up) Edges(QuadOrientation orientation, Vector3 velocity) + { + var particle = new ParticleState(new Vector3(0, 0, -10), velocity, new Vector2(1, 1), 0, Vector3.One, 1, 1, 0); + var v = ParticleRenderItem.Quads(new EmitterSettings { Orientation = orientation, StretchCap = 3 }, DownMinusZ, [particle]); + return (v[1].Position - v[0].Position, v[5].Position - v[0].Position); + } + + // Mode 5 stretches by as much of the velocity as crosses the screen, never under its own size: seen down its path it is + // a round burst, not a full-length streak along whatever crumb of the velocity is left on the screen. + [Test] + public void CameraAlignedWithStretching_SeenHeadOn_IsNotStretched() + { + var (across, up) = Edges(QuadOrientation.VelocityAligned, HeadOn); + Assert.That((across.Length(), up.Length()), Is.EqualTo((1f, 1f)).Within(1e-3)); + } + + [Test] + public void CameraAlignedWithStretching_SeenSideOn_StretchesAlongTheVelocity() + { + var (across, up) = Edges(QuadOrientation.VelocityAligned, new Vector3(10, 0, 0)); + Assert.Multiple(() => + { + Assert.That(up.Length(), Is.EqualTo(3).Within(1e-3)); + Assert.That(Vector3.Dot(Vector3.Normalize(up), Vector3.UnitX), Is.EqualTo(1).Within(1e-4)); + Assert.That(Vector3.Dot(across, Vector3.UnitZ), Is.EqualTo(0).Within(1e-4), "facing the camera"); + }); + } + + // Mode 2 lies along the velocity itself, so seen head-on it is foreshortened by the view rather than turned flat to it. + [Test] + public void CameraFacingVelocityAligned_LiesAlongTheVelocity() + { + var (_, up) = Edges(QuadOrientation.VelocityAxis, HeadOn); + Assert.Multiple(() => + { + Assert.That(up.Length(), Is.EqualTo(3).Within(1e-3)); + Assert.That(Vector3.Dot(Vector3.Normalize(up), Vector3.Normalize(HeadOn)), Is.EqualTo(1).Within(1e-4)); + }); + } + + // A decal lies flat on the world's ground under its particle, whatever its own orientation and however the effect is + // carried: here a camera-facing particle 2 m up in an effect lifted 1 m and tipped 90 degrees about X. + [Test] + public void Decal_LiesFlatOnTheGroundUnderItsParticle() + { + var effect = Matrix.CreateRotationX(MathHelper.PiOver2) * Matrix.CreateTranslation(3, 1, 0); + var camera = ParticleRenderItem.CameraAxes.From(View, effect); + var particle = new ParticleState(new Vector3(0, 2, 0), Vector3.Zero, new Vector2(2, 2), 0, Vector3.One, 1, 0, 0); + var corners = ParticleRenderItem.Quads(new EmitterSettings { Decal = true }, camera, [particle]).Select(v => Vector3.Transform(v.Position, effect)).ToList(); + Assert.Multiple(() => + { + Assert.That(corners.Select(c => c.Y), Is.All.EqualTo(0.01f).Within(1e-4), "on the ground"); + var middle = corners.Aggregate(Vector3.Zero, (sum, c) => sum + c) / corners.Count; + Assert.That(new[] { middle.X, middle.Z }, Is.EqualTo(new[] { 3f, 2f }).Within(1e-4), "under the particle"); + Assert.That(corners.Max(c => c.X) - corners.Min(c => c.X), Is.EqualTo(2).Within(1e-3), "as wide as it"); + }); + } + + // Seen from above, 5 m up, with the scene's light particles as the engine hands them (Lights() of every item). + static CommonShaderParameters Above(IEnumerable lights) => new(Matrix.CreateLookAt(new Vector3(0, 5, 0), Vector3.Zero, Vector3.Forward), + Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, 1, 0.1f, 100), new Vector3(0, 5, 0), Vector3.Zero, 0, 0, 0, 1, [], Vector3.One) { PointLights = lights.ToList() }; + + // A white light particle at this alpha, as Lights() hands it the scene: colour x alpha at CA's share of the sun. + static PointLight White(Vector3 at, float reach, float alpha) => new(at, reach, Vector3.One * alpha * VfxConventions.LightShare); + + // The light CA's ps_main_deferred_dynamic_light puts on the ground: colour x alpha x 2500 on the scale of the models' sun + // (20000 x 3 at intensity 1, both taking sin^2(0.5 deg)), times the battle's dynamic_light_scale of 0.1, on a mid-grey ground + // of albedo 0.18, through the models' tone map. + static float Expected(float lit) => Encode(Math.Clamp(Uncharted2(0.18f * 2500 * 0.1f * MathF.Pow(MathF.Sin(MathHelper.ToRadians(0.5f)), 2) * lit), 0, 1)); + + // A dynamic light lights the ground under it, its alpha not held to 1, reaching as far from the light as the particle is + // wide and falling off as (1 - d/r)^2, as CA's does with its smooth falloff. Over black, a white light 40 m across: a quarter + // of its reach up it lights the centre at 0.5625 of its strength, at alpha 10 and at 200; half its reach up, at 0.25; its + // reach up, not at all. + [TestCase(10f, 10f, 0.5625f)] + [TestCase(10f, 200f, 0.5625f)] + [TestCase(20f, 200f, 0.25f)] + [TestCase(40f, 200f, 0f)] + public void Light_LightsTheGroundUnderIt(float height, float alpha, float fall) + { + var pixel = Render(() => ParticleRenderItem.DrawGroundLight(_device, Shader, Above([White(new Vector3(0, height, 0), 40, alpha)])), Color.Black); + Assert.That(pixel.X, Is.EqualTo(Expected(alpha * fall)).Within(0.01)); + } + + // ...all round it, by how squarely the ground meets it: one 20 m up and 6 m off to either side lights the ground under the + // camera. + [TestCase(0f, 6f)] + [TestCase(6f, 0f)] + public void Light_LightsTheGroundAllRound(float x, float z) + { + var pixel = Render(() => ParticleRenderItem.DrawGroundLight(_device, Shader, Above([White(new Vector3(x, 20, z), 40, 200)])), Color.Black); + var d = MathF.Sqrt(6 * 6 + 20 * 20); + Assert.That(pixel.X, Is.EqualTo(Expected(200 * MathF.Pow(1 - d / 40, 2) * (20 / d))).Within(0.01)); + } + + // CA adds up every light reaching a spot of ground and tone maps the sum once: lights tone mapped one by one and added after + // ran the Bale Taurus's leg lights to white. Lights 40 m wide and 20 m up: four at alpha 200 on one spot light it as one light + // four times as strong; one there and one 100 m off, out of reach, as the one alone; and the 35 a Bale Taurus stands among, + // at alpha 10, as 35 times one, every one counted once. + [TestCase(4, 0, 200f)] + [TestCase(1, 1, 200f)] + [TestCase(35, 0, 10f)] + public void Light_AllTheLightsOnASpotAddUpBeforeTheToneMap(int overhead, int away, float alpha) + { + var at = Enumerable.Repeat(new Vector3(0, 20, 0), overhead).Concat(Enumerable.Repeat(new Vector3(100, 20, 0), away)); + var pixel = Render(() => ParticleRenderItem.DrawGroundLight(_device, Shader, Above(at.Select(x => White(x, 40, alpha)))), Color.Black); + Assert.That(pixel.X, Is.EqualTo(Expected(overhead * alpha * 0.25f)).Within(0.01)); + } + + // A light particle draws nothing of its own, in either pass: the engine draws the light all of them put on the ground at once + // (DrawGroundLight), and light blooms no more than a lit model does. + [TestCase(false)] + [TestCase(true)] + public void Light_AParticleDrawsNothingItself(bool glow) + { + var down = Above([]); + var light = new ParticleState(new Vector3(0, 1, 0), Vector3.Zero, new Vector2(40, 40), 0, Vector3.One, 20, 0, 0); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, down, Matrix.Identity, ParticleRenderItem.CameraAxes.From(down, Matrix.Identity), + new EmitterSettings { Decal = true, Light = true }, _white, [light], glow: glow), Color.Black); + Assert.That(pixel.X, Is.EqualTo(0).Within(0.01)); + } + + // CA reads a distortion map's x from alpha and its y from green (ps_distortion_alphablend samples it .ywxz); red is unused, and + // is 255 in every texel of shockwave_normal.dds. Read as x, it moved the whole quad's scene sideways: the Slayer Pirates' rum + // haze laid a second dwarf beside the first. + [Test] + public void Distortion_ReadsXFromAlpha_NotRed() + { + using var cache = new VfxTextureCache((_, _) => Dxt5nm(128)); + using var item = new ParticleRenderItem(OneParticle("normal", distortion: true), cache, () => Shader); + Assert.That(Shift(HazeOver(item, At(5), Ramp)), Is.EqualTo(0).Within(0.5)); + } + + // fx_hdr_to_screen moves the finished scene by 0.02 of the view per unit of the distortion buffer, in screen space: the same + // share at any distance, however big the particle. A full tilt at full alpha is 0.02 of a view 512 wide. + [TestCase(1f)] + [TestCase(5f)] + public void Distortion_MovesAFixedShareOfTheView_AtAnyDistance(float distance) + { + using var cache = new VfxTextureCache((_, _) => Dxt5nm(255)); + using var item = new ParticleRenderItem(OneParticle("normal", distortion: true), cache, () => Shader); + Assert.That(Shift(HazeOver(item, At(distance), Ramp)), Is.EqualTo(0.02f * 512).Within(0.5)); + } + + // The particle writes its alpha times its tilt, a, as the buffer's cover and a times the tilt as the shift; the buffer keeps + // what is left uncovered, and the screen pass weighs the shift by the cover, so one particle moves the scene 0.02 x a^2 x tilt. + [TestCase(1f, 255, 1f)] + [TestCase(0.5f, 255, 0.25f)] + [TestCase(1f, 191, 0.125f)] + public void Distortion_WeighsTheShiftByAlphaAndTilt(float alpha, int x, float share) + { + using var cache = new VfxTextureCache((_, _) => Dxt5nm(x)); + using var item = new ParticleRenderItem(OneParticle("normal", new Vector4(1, 1, 1, alpha), distortion: true), cache, () => Shader); + Assert.That(Shift(HazeOver(item, At(5), Ramp)), Is.EqualTo(0.02f * 512 * share).Within(0.5)); + } + + // The screen pass moves each pixel; it does not lay a moved copy over the scene. At half alpha the haze moves the centre 2.6 + // pixels, onto blue: a copy laid over at half alpha left it half red. + [Test] + public void Distortion_MovesThePixel_LeavesNoCopyBehind() + { + using var cache = new VfxTextureCache((_, _) => Dxt5nm(255)); + using var item = new ParticleRenderItem(OneParticle("normal", new Vector4(1, 1, 1, 0.5f), distortion: true), cache, () => Shader); + var pixel = HazeOver(item, At(5), x => x <= HazeWidth / 2 ? Color.Red : Color.Blue); + Assert.That(new[] { pixel.R, pixel.B }, Is.EqualTo(new[] { 0, 255 }).Within(3)); + } + + // The app lays its scene over the background by alpha, so the scene's alpha moves with it: a model's edge moved over empty + // view leaves the background showing there, not the model where it was. + [Test] + public void Distortion_MovesTheScenesAlphaToo() + { + using var cache = new VfxTextureCache((_, _) => Dxt5nm(255)); + using var item = new ParticleRenderItem(OneParticle("normal", distortion: true), cache, () => Shader); + var pixel = HazeOver(item, At(5), x => x <= HazeWidth / 2 ? Color.Red : Color.Transparent); + Assert.That(pixel.A, Is.EqualTo(0).Within(3)); + } + + // A DXT5nm normal map of one texel, as CA's are: x in alpha, y in green, red and blue unused. + Texture2D Dxt5nm(int x, int y = 128) + { + var normal = new Texture2D(_device, 1, 1); + normal.SetData(new[] { new Color(255, y, 0, x) }); + return normal; + } + + static CommonShaderParameters At(float distance) => new(Matrix.CreateLookAt(new Vector3(0, 0, distance), Vector3.Zero, Vector3.Up), + Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, 1, 0.1f, 100), new Vector3(0, 0, distance), Vector3.Zero, 0, 0, 0, 1, [], Vector3.One); + + const int HazeWidth = 512; + + // A red ramp, 4 levels a pixel across the middle 64 of the view, 128 at the centre pixel: the red the centre ends up with + // says where the scene under it was read from. + static Color Ramp(int x) => new(Math.Clamp((x - HazeWidth / 2 + 32) * 4, 0, 255), 0, 0, 255); + + static float Shift(Color centre) => centre.R / 4f - 32; + + // The centre pixel of a view HazeWidth across, after a haze draws over a scene of these columns. + Color HazeOver(ParticleRenderItem item, CommonShaderParameters view, Func scene) + { + using var target = new RenderTarget2D(_device, HazeWidth, 8, false, SurfaceFormat.Color, DepthFormat.None, 0, RenderTargetUsage.PreserveContents); + using var columns = new Texture2D(_device, HazeWidth, 1); + columns.SetData(Enumerable.Range(0, HazeWidth).Select(scene).ToArray()); + using var batch = new SpriteBatch(_device); + _device.SetRenderTarget(target); + batch.Begin(SpriteSortMode.Immediate, BlendState.Opaque, SamplerState.PointClamp); + batch.Draw(columns, new Rectangle(0, 0, HazeWidth, 8), Color.White); + batch.End(); + _device.DepthStencilState = DepthStencilState.None; + _device.RasterizerState = RasterizerState.CullNone; + item.Draw(_device, view, RenderingTechnique.Normal); + _device.SetRenderTarget(null); + var pixels = new Color[HazeWidth * 8]; + target.GetData(pixels); + return pixels[4 * HazeWidth + HazeWidth / 2]; + } + + // A rigid particle's model: its pivot (a share of its box) on the particle, scaled by its size, turned X, Y, then Z. + [Test] + public void RigidMatrix_PutsThePivotOnTheParticle() + { + var box = new BoundingBox(Vector3.Zero, new Vector3(2, 4, 2)); + var m = VfxConventions.RigidMatrix(box, new Vector3(0.5f, 0, 0.5f), 3, new Vector3(0, MathHelper.PiOver2, 0), Matrix.Identity, new Vector3(10, 0, 0)); + Assert.That(Vector3.Distance(Vector3.Transform(new Vector3(1, 0, 1), m), new Vector3(10, 0, 0)), Is.LessThan(1e-4), "the pivot"); + Assert.That(Vector3.Distance(Vector3.Transform(new Vector3(2, 0, 1), m), new Vector3(10, 0, -3)), Is.LessThan(1e-4), "+X a metre off, scaled 3, turned to -Z"); + var framed = VfxConventions.RigidMatrix(box, new Vector3(0.5f, 0, 0.5f), 3, new Vector3(0, MathHelper.PiOver2, 0), Matrix.CreateRotationX(MathHelper.PiOver2), new Vector3(10, 0, 0)); + Assert.That(Vector3.Distance(Vector3.Transform(new Vector3(2, 0, 1), framed), new Vector3(10, 3, 0)), Is.LessThan(1e-4), "then turned by its emitter's frame"); + } + + // The preview's PBR materials stand in for CA's character and rigid shaders; the rest (Tidecall's water, the dissolves, + // the unlit vortices: most of vanilla's rigid VFX models) are CA's own, which it cannot run. + [TestCase("shaders/wh2_vfx_cst_tidecall.xml.shader", true)] + [TestCase("shaders/wh3_vfx_rigid_emissive_dissolve.xml.shader", true)] + [TestCase("shaders/campaign_vortex_unlit.xml.shader", true)] + [TestCase("shaders/weighted4_character_alpha.xml.shader", false)] + [TestCase("shaders/weighted2_character_emissive.xml.shader", false)] + [TestCase("shaders/rigid_character.xml.shader", false)] + [TestCase("shaders/rigid_default.xml.shader", false)] + [TestCase("shaders/weighted2_default.xml.shader", false)] + [TestCase("SHADERS/System/Weighted_Standard_4.xml.shader", false)] + [TestCase("", false)] + public void IsCustomShader_IsAnyButTheCharacterAndRigidOnes(string shader, bool custom) => + Assert.That(VfxConventions.IsCustomShader(shader), Is.EqualTo(custom)); + + // A rigid particle's AnimFile plays at AnimSpeed from the particle's birth and loops: most of vanilla's 118 are run + // cycles, idles and tentacles. A clip with no length holds its first frame. + [TestCase(0.5f, 2f, 4f, 0.25f)] + [TestCase(5f, 1f, 2f, 0.5f)] + [TestCase(1f, 1f, 0f, 0f)] + public void AnimShare_PlaysAtItsSpeedAndLoops(float age, float speed, float length, float share) => + Assert.That(VfxConventions.AnimShare(age, speed, length), Is.EqualTo(share).Within(1e-5)); + + // A rigid particle takes its emitter's turn, without its place or scale; one RELATIVE_TO_TERRAIN_NORMAL stands on the + // preview's flat ground, so it keeps only the emitter's heading. + [Test] + public void RigidFrame_TurnsWithTheEmitter_AndOnTheGroundKeepsOnlyItsHeading() + { + var frame = Matrix.CreateScale(2) * Matrix.CreateRotationX(MathHelper.PiOver4) * Matrix.CreateRotationY(MathHelper.PiOver2) * Matrix.CreateTranslation(5, 6, 7); + var turned = VfxConventions.RigidFrame(frame, onTerrain: false); + var heading = VfxConventions.RigidFrame(frame, onTerrain: true); + Assert.Multiple(() => + { + Assert.That(Vector3.Distance(Vector3.Transform(Vector3.UnitY, turned), Vector3.Normalize(Vector3.TransformNormal(Vector3.UnitY, frame))), Is.LessThan(1e-4), "turned"); + Assert.That(Vector3.Distance(Vector3.Transform(Vector3.UnitY, heading), Vector3.UnitY), Is.LessThan(1e-4), "upright"); + Assert.That(Vector3.Distance(Vector3.Transform(Vector3.UnitX, heading), -Vector3.UnitZ), Is.LessThan(1e-4), "heading"); + }); + } + + // Each model is loaded once per editor, a miss included; one that loads with no meshes draws nothing. + [Test] + public void MeshCache_LoadsEachModelOnce() + { + var loads = 0; + using var cache = new VfxMeshCache(path => { loads++; return path == "missing" ? null : new GroupNode("empty"); }); + cache.Get("vfx/a.wsmodel"); + cache.Get(@"VFX\A.wsmodel"); + cache.Get("missing"); + cache.Get("missing"); + Assert.That((loads, cache.Get("vfx/a.wsmodel").Meshes.Count), Is.EqualTo((2, 0))); + } + + // A wsmodel is built from a geometry file and material files the cache does not track, so a change to any model file + // reloads every model; a texture or an effect file reloads none. + [TestCase(@"variantmeshes\wh_variantmodels\x\x.rigid_model_v2", 2)] + [TestCase("vfx/models/b.wsmodel", 2)] + [TestCase("vfx/materials/wave.xml.material", 2)] + [TestCase("vfx/textures/wave.dds", 1)] + [TestCase("vfx/fx.xml", 1)] + public void MeshCache_ForgetsEveryModelWhenAModelFileChanges(string changed, int loads) + { + var count = 0; + using var cache = new VfxMeshCache(path => { count++; return new GroupNode("empty"); }); + cache.Get("vfx/models/a.wsmodel"); + cache.Forget(changed); + cache.Get("vfx/models/a.wsmodel"); + Assert.That(count, Is.EqualTo(loads)); + } + + // enable_trail_mode: one strip through the particles, oldest to newest, as wide as each, facing the camera, with + // the texture cell running from the newest (its top, as a velocity-aligned quad's head) to the oldest. + [Test] + public void Ribbon_JoinsTheParticles_AsWideAsEach_TextureHeadToTail() + { + ParticleState At(float x, float width) => new(new Vector3(x, 0, 0), Vector3.Zero, new Vector2(width, width), 0, Vector3.One, 1, 0, 0); + var vertices = ParticleRenderItem.Ribbon(new EmitterSettings { Trail = true }, ParticleRenderItem.CameraAxes.From(View, Matrix.Identity), [At(0, 0.2f), At(1, 0.4f), At(2, 0.6f)]); + Assert.That(vertices, Has.Length.EqualTo(12), "two segments of two triangles"); + var edges = vertices.Select(v => (X: MathF.Round(v.Position.X, 3), HalfWidth: MathF.Round(MathF.Abs(v.Position.Y), 3), V: v.TextureCoordinate.Y)).Distinct().OrderBy(x => x.X).ToList(); + Assert.That(edges, Is.EqualTo(new[] { (0f, 0.1f, 1f), (1f, 0.2f, 0.5f), (2f, 0.3f, 0f) })); + Assert.That(vertices.All(v => v.Position.Z == 0), Is.True, "faces the camera on +Z"); + } + + // A trail draws as its particles would as quads: mirrored by its newest particle's flip, grown to vs_rendersize_limiter's + // pixels across (2 m for a 10 cm strip 100 m away, where a metre at a metre is 100 pixels), and faded by ps_soft_edge where + // it runs into the ground (half, 0.5 m up under a camera looking down, with a 1 m soft_factor). + [Test] + public void Ribbon_FlipsGrowsAndSoftensLikeAQuad() + { + ParticleState At(float x, float y = 0, float width = 0.1f, bool flipU = false) => + new(new Vector3(x, y, 0), Vector3.Zero, new Vector2(width), 0, Vector3.One, 1, 0, 0, FlipU: flipU); + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var far = camera with { Position = new Vector3(0, 0, 100), PixelScale = 100 }; + var looking = new ParticleRenderItem.CameraAxes(new Vector3(0, 10, 0), Vector3.Right, Vector3.Forward, Vector3.Down); + var trail = new EmitterSettings { Trail = true, Premultiplied = false }; + Assert.Multiple(() => + { + Assert.That(ParticleRenderItem.Ribbon(trail, camera, [At(0), At(1)])[0].TextureCoordinate.X, Is.EqualTo(0f), "plain"); + Assert.That(ParticleRenderItem.Ribbon(trail, camera, [At(0), At(1, flipU: true)])[0].TextureCoordinate.X, Is.EqualTo(1f), "mirrored"); + var grown = ParticleRenderItem.Ribbon(new EmitterSettings { Trail = true, MinPixels = 2 }, far, [At(0), At(1)]); + Assert.That(grown.Max(v => MathF.Abs(v.Position.Y)), Is.EqualTo(1f).Within(1e-2), "grown"); + var soft = ParticleRenderItem.Ribbon(new EmitterSettings { Trail = true, Premultiplied = false, SoftEdge = 1, SoftStrength = 1 }, looking, + [At(0, 0.5f, 1), At(1, 0.5f, 1)]); + Assert.That(soft.Max(v => v.Color.A / 255f), Is.EqualTo(0.5f).Within(0.02), "softened"); + }); + } + + // Two particles off the sides of the view: as quads they leave its middle grey, as a trail the strip crosses it. + [TestCase(false, 0.502f)] + [TestCase(true, 1f)] + public void TrailMode_DrawsTheStripBetweenTheParticles(bool trail, float red) + { + ParticleState At(float x) => new(new Vector3(x, 0, 0), Vector3.Zero, new Vector2(2, 2), 0, new Vector3(1, 0, 0), 1, 0, 0); + var effect = Shader; + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, effect, View, Matrix.Identity, camera, new EmitterSettings { Trail = trail }, _white, [At(-4), At(4)])); + Assert.That(pixel.X, Is.EqualTo(red).Within(0.02)); + } + + // The quad's bottom-left corner: a mirrored particle samples the texture's right edge there, and a quarter turn + // counter-clockwise brings the texture's top-left corner down to it. + [Test] + public void Quads_MirrorAndTurnTheTexture() + { + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + Vector2 BottomLeft(EmitterSettings s, bool flipU) => ParticleRenderItem.Quads(s, camera, [RedHalfAlpha with { FlipU = flipU }])[0].TextureCoordinate; + Assert.That(BottomLeft(new EmitterSettings(), false), Is.EqualTo(new Vector2(0, 1))); + Assert.That(BottomLeft(new EmitterSettings(), true), Is.EqualTo(new Vector2(1, 1))); + Assert.That(BottomLeft(new EmitterSettings { UvTurn = 1 }, false), Is.EqualTo(new Vector2(0, 0))); + } + + // The axis each rare orientation's quad lies flat across, seen from (10, 10, 0): an upright quad turns about Y to face + // the camera, and the local ones lie in the emitter's own frame, here turned 90 degrees about X or Y. + [TestCase(QuadOrientation.Vertical, 0f, 0f, 0)] + [TestCase(QuadOrientation.LocalHorizontal, 90f, 0f, 2)] + [TestCase(QuadOrientation.LocalVertical, 0f, 90f, 0)] + public void RareOrientations_LieFlatAcrossTheirAxis(QuadOrientation mode, float aboutX, float aboutY, int flat) + { + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity) with { Position = new Vector3(10, 10, 0) }; + var frame = Matrix.CreateRotationX(MathHelper.ToRadians(aboutX)) * Matrix.CreateRotationY(MathHelper.ToRadians(aboutY)); + var corners = ParticleRenderItem.Quads(new EmitterSettings { Orientation = mode }, camera, [RedHalfAlpha], frame).Select(v => v.Position).ToList(); + float Axis(Vector3 v, int i) => i == 0 ? v.X : i == 1 ? v.Y : v.Z; + Assert.That(corners.Max(v => Math.Abs(Axis(v, flat))), Is.LessThan(1e-3f)); + foreach (var other in new[] { 0, 1, 2 }.Where(i => i != flat)) + Assert.That(corners.Max(v => Axis(v, other)) - corners.Min(v => Axis(v, other)), Is.GreaterThan(9f), $"spans axis {other}"); + } + + // vs_translation's restriction, as CA's gs_translation builds the quad, under a camera whose up leans 45 degrees toward + // +Z, so a camera-facing quad lies across no axis. 1 stands it on the world's Y and spreads it along the camera's right + // (+X), so it lies across Z whatever the camera's position (the name-read Vertical turns to the particle instead). 2 + // lays it in the world's XZ plane: with the effect turned 90 degrees about X, that is the model's XY. 3 turns it by + // the particle's X, Y and Z and then its emitter, as a rigid particle turns. + [TestCase(QuadOrientation.Upright, 0f, 0f, 0f, 2)] + [TestCase(QuadOrientation.Flat, 0f, 0f, 0f, 1)] + [TestCase(QuadOrientation.Flat, 0f, 0f, 90f, 2)] + [TestCase(QuadOrientation.Object3D, 0f, 0f, 0f, 2)] + [TestCase(QuadOrientation.Object3D, 90f, 0f, 0f, 1)] + [TestCase(QuadOrientation.Object3D, 0f, 90f, 0f, 0)] + public void Restrictions_LieFlatAcrossTheirAxis(QuadOrientation mode, float turnX, float frameAboutY, float worldAboutX, int flat) + { + var world = Matrix.CreateRotationX(MathHelper.ToRadians(worldAboutX)); + var camera = ParticleRenderItem.CameraAxes.From(View, world) with { Position = new Vector3(10, 10, 0), Up = Vector3.Normalize(new Vector3(0, 1, 1)) }; + var particle = RedHalfAlpha with { TurnXy = new Vector2(MathHelper.ToRadians(turnX), 0) }; + var frame = Matrix.CreateRotationY(MathHelper.ToRadians(frameAboutY)); + var corners = ParticleRenderItem.Quads(new EmitterSettings { Orientation = mode }, camera, [particle], frame).Select(v => v.Position).ToList(); + float Axis(Vector3 v, int i) => i == 0 ? v.X : i == 1 ? v.Y : v.Z; + Assert.That(corners.Max(v => Math.Abs(Axis(v, flat))), Is.LessThan(1e-3f)); + foreach (var other in new[] { 0, 1, 2 }.Where(i => i != flat)) + Assert.That(corners.Max(v => Axis(v, other)) - corners.Min(v => Axis(v, other)), Is.GreaterThan(9f), $"spans axis {other}"); + } + + // On the ground (ParticleRelativeSpawnOrientation RELATIVE_TO_TERRAIN_NORMAL) a 3D quad keeps only its emitter's heading, + // as a rigid model does: under an emitter tipped 90 degrees about X it stays in the XY plane, where it would otherwise + // turn with the emitter into XZ. + [TestCase(true, 2)] + [TestCase(false, 1)] + public void Object3DQuads_OnTheGround_KeepOnlyTheEmittersHeading(bool onGround, int flat) + { + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var s = new EmitterSettings { Orientation = QuadOrientation.Object3D, RigidOnTerrain = onGround }; + var corners = ParticleRenderItem.Quads(s, camera, [RedHalfAlpha], Matrix.CreateRotationX(MathHelper.PiOver2)).Select(v => v.Position).ToList(); + float Axis(Vector3 v, int i) => i == 0 ? v.X : i == 1 ? v.Y : v.Z; + Assert.That(corners.Max(v => Math.Abs(Axis(v, flat))), Is.LessThan(1e-3f)); + } + + // ParticleCullDistances fades a particle in from x to y metres from the camera and out from z to w; CullDistances does + // the same to the whole emitter by its own distance. Seen from 3 m, the middle of a 2..4 band is half faded. + [Test] + public void CullDistances_FadeByDistanceFromTheCamera() + { + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity) with { Position = new Vector3(0, 0, 3) }; + float Alpha(EmitterSettings s) => ParticleRenderItem.Quads(s, camera, [RedHalfAlpha])[0].Color.A / 255f; + Assert.Multiple(() => + { + Assert.That(Alpha(new EmitterSettings { Premultiplied = false }), Is.EqualTo(0.5f).Within(0.01)); + Assert.That(Alpha(new EmitterSettings { Premultiplied = false, ParticleCull = new Vector4(2, 4, 100, 200) }), Is.EqualTo(0.25f).Within(0.01)); + Assert.That(Alpha(new EmitterSettings { Premultiplied = false, EmitterCull = new Vector4(0, 0, 1, 5) }), Is.EqualTo(0.25f).Within(0.01)); + Assert.That(Alpha(new EmitterSettings { Premultiplied = false, ParticleCull = new Vector4(0, 0, 1, 2) }), Is.EqualTo(0f), "past the far distance"); + }); + } + + // vs_rendersize_limiter: a 10 cm spark 100 m away covers a tenth of a pixel where a metre at a metre is 100 pixels; a + // 2 pixel limit grows it to 2 m across, and without one it stays as it is. + [TestCase(0f, 0.1f)] + [TestCase(2f, 2f)] + public void RenderSizeLimiter_KeepsAFarParticleThisManyPixelsAcross(float minPixels, float across) + { + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity) with { Position = new Vector3(0, 0, 100), PixelScale = 100 }; + var spark = RedHalfAlpha with { Size = new Vector2(0.1f, 0.1f) }; + var xs = ParticleRenderItem.Quads(new EmitterSettings { MinPixels = minPixels }, camera, [spark]).Select(v => v.Position.X).ToList(); + Assert.That(xs.Max() - xs.Min(), Is.EqualTo(across).Within(1e-3)); + } + + [Test] + public void UvRect_AtlasCellThenFlipbookCell() + { + var atlas = new EmitterSettings { AtlasX = 8, AtlasY = 8, AtlasCell = 51, CellsX = 2, CellsY = 2 }; + Assert.That(ParticleRenderItem.UvRect(atlas, 3), Is.EqualTo(new Vector4(0.4375f, 0.8125f, 0.0625f, 0.0625f))); + var strip = new EmitterSettings { CellsX = 8, CellsY = 8 }; + Assert.That(ParticleRenderItem.UvRect(strip, 9), Is.EqualTo(new Vector4(0.125f, 0.125f, 0.125f, 0.125f))); + } + + // One particle 10 m across at the origin, filling the view. + static ParticleSimulator OneParticle(string? texture, Vector4? tint = null, bool distortion = false) => Particles(Filling(texture, tint, distortion)); + + // An emitter of one particle 10 m across, z metres along the view axis. + static EmitterSettings Filling(string? texture, Vector4? tint = null, bool distortion = false, float z = 0) => new() + { + Name = "test", InfiniteLife = true, Rate = VfxCurve.Constant(60), MaxParticles = 1, ParticleLife = VfxCurve.Constant(100), + InitialScale = VfxCurve.Constant(10, 10, 10), Texture = texture, TintMin = tint ?? Vector4.One, TintMax = tint ?? Vector4.One, Distortion = distortion, + ReferenceTransform = Matrix.CreateTranslation(0, 0, z), + }; + + static ParticleSimulator Particles(params EmitterSettings[] settings) + { + var simulator = new ParticleSimulator(settings, 1, _ => Matrix.Identity); + simulator.AdvanceTo(0.5f); + return simulator; + } + + // A heat haze shifts what lies behind it when it draws, the effect's own particles included: copied once before any + // of them, it pasted the bare scene over the mist (tidecall's ripples over its splashes). A flat normal shifts + // nothing, so the red particle behind stays red. + [Test] + public void Distortion_ShiftsTheEffectsOwnParticlesBehindIt() + { + using var cache = new VfxTextureCache((_, _) => Dxt5nm(128)); + using var item = new ParticleRenderItem(Particles(Filling(null, new Vector4(1, 0, 0, 1), z: -1), Filling("normal", distortion: true)), cache, () => Shader); + var pixel = Render(() => item.Draw(_device, View, RenderingTechnique.Normal)); + Assert.That(new[] { pixel.X, pixel.Y }, Is.EqualTo(new[] { 1f, 0f }).Within(0.02)); + } + + // Emitters are drawn back to front, so the nearer lies over the farther whichever the file lists first. + [Test] + public void Emitters_AreDrawnFarthestFirst() + { + using var item = new ParticleRenderItem(Particles(Filling(null, new Vector4(0, 0, 1, 1), z: 1), Filling(null, new Vector4(1, 0, 0, 1), z: -1)), NoTextures(), () => Shader); + var pixel = Render(() => item.Draw(_device, View, RenderingTechnique.Normal)); + Assert.That(new[] { pixel.X, pixel.Z }, Is.EqualTo(new[] { 0f, 1f }).Within(0.02)); + } + + // ...and so are an emitter's particles. + [Test] + public void Quads_TakeTheFarthestParticleFirst() + { + var camera = ParticleRenderItem.CameraAxes.From(View, Matrix.Identity); + var vertices = ParticleRenderItem.Quads(new EmitterSettings(), camera, [RedHalfAlpha with { Position = new Vector3(0, 0, 1) }, RedHalfAlpha with { Position = new Vector3(0, 0, -1) }]); + Assert.That(vertices.Take(6).Select(v => v.Position.Z), Is.All.EqualTo(-1f).Within(1e-4)); + } + + // A decal lies on the ground, so it does not fade into it: over a scene depth that is the ground 5 m below, it draws whole. + [Test] + public void Decal_DoesNotFadeIntoTheGroundItLiesOn() + { + using var depth = new Texture2D(_device, 4, 4, false, SurfaceFormat.Single); + depth.SetData(Enumerable.Repeat(5f, 16).ToArray()); + var down = Above([]) with { SceneDepth = depth }; + var particle = new ParticleState(new Vector3(0, 1, 0), Vector3.Zero, new Vector2(10, 10), 0, Vector3.One, 1, 0, 0); + var settings = new EmitterSettings { Decal = true, Premultiplied = false, SoftEdge = 1 }; + var pixel = Render(() => ParticleRenderItem.DrawEmitter(_device, Shader, down, Matrix.Identity, ParticleRenderItem.CameraAxes.From(down, Matrix.Identity), settings, _white, [particle]), Color.Black); + Assert.That(pixel.X, Is.EqualTo(1).Within(0.02)); + } + + // The engine's depth pass draws an effect's rigid models, the only part of it that is solid. + [Test] + public void DepthPass_DrawsTheRigidModels() + { + var asked = new List(); + using var meshes = new VfxMeshCache(path => { asked.Add(path); return new GroupNode("empty"); }); + var rigid = Filling(null); + rigid.Rigid = [new VfxRigidEntry("vfx/models/rock.wsmodel", new Vector3(0.5f), null)]; + using var item = new ParticleRenderItem(Particles(rigid), NoTextures(), () => Shader, meshes); + Render(() => item.Draw(_device, View, RenderingTechnique.Depth)); + Assert.That(asked, Is.EqualTo(new[] { "vfx/models/rock.wsmodel" })); + } + + // The haze's copy of the scene leaves the caller's sampler as it found it: it left point sampling behind. + [Test] + public void Distortion_LeavesTheCallersSampler() + { + using var cache = new VfxTextureCache((_, _) => Dxt5nm(128)); + using var item = new ParticleRenderItem(OneParticle("normal", distortion: true), cache, () => Shader); + SamplerState? after = null; + Render(() => + { + _device.SamplerStates[0] = SamplerState.AnisotropicWrap; + item.Draw(_device, View, RenderingTechnique.Normal); + after = _device.SamplerStates[0]; + }); + Assert.That(after, Is.SameAs(SamplerState.AnisotropicWrap)); + } + + // The particle shader's own samplers (the scene copy, the scene depth: clamped, point-filtered) stayed on the device after + // its passes, and the models take whatever the device holds: theirs drew striped, in every tab, since the device is shared. + [Test] + public void Draw_LeavesEverySamplerAsItFoundIt() + { + using var depth = new Texture2D(_device, 4, 4, false, SurfaceFormat.Single); + depth.SetData(Enumerable.Repeat(7f, 16).ToArray()); + var soft = Filling(null); + soft.SoftEdge = 1; + using var cache = new VfxTextureCache((_, _) => Dxt5nm(128)); + using var item = new ParticleRenderItem(Particles(soft, Filling("normal", distortion: true)), cache, () => Shader); + var after = new SamplerState[16]; + Render(() => + { + for (var i = 0; i < after.Length; i++) + _device.SamplerStates[i] = SamplerState.AnisotropicWrap; + item.Draw(_device, View with { SceneDepth = depth }, RenderingTechnique.Normal); + for (var i = 0; i < after.Length; i++) + after[i] = _device.SamplerStates[i]; + }); + Assert.That(after, Is.All.SameAs(SamplerState.AnisotropicWrap)); + } + + static VfxTextureCache NoTextures() => new((_, _) => null); + + [Test] + public void MissingTexture_DrawsWhite() + { + using var item = new ParticleRenderItem(OneParticle(@"vfx\textures\missing.dds"), NoTextures(), () => Shader); + var pixel = Render(() => item.Draw(_device, View, RenderingTechnique.Normal)); + Assert.That(new[] { pixel.X, pixel.Y, pixel.Z }, Is.All.EqualTo(1).Within(0.02)); + } + + // SuperView disposes an effect twice (CleanUp, then the scene's own dispose), and a frame can land + // between: a draw after Dispose must build its effect again, not draw with disposed objects. + [Test] + public void DrawAfterDispose_DrawsAgain() + { + using var item = new ParticleRenderItem(OneParticle(null), NoTextures(), () => Shader); + Render(() => item.Draw(_device, View, RenderingTechnique.Normal)); + item.Dispose(); + var pixel = Render(() => item.Draw(_device, View, RenderingTechnique.Normal)); + Assert.That(new[] { pixel.X, pixel.Y, pixel.Z }, Is.All.EqualTo(1).Within(0.02)); + } + + // SuperView's scene is disposed node by node when its editor closes, and only IDisposable nodes are + // reached: a VfxEffectNode that was not one kept its GPU objects for the life of the shared device. + [Test] + public void DisposingTheEffectNode_DisposesItsRenderItem() + { + var item = new ParticleRenderItem(OneParticle(null), NoTextures(), () => Shader); + Render(() => item.Draw(_device, View, RenderingTechnique.Normal)); + (new VfxEffectNode("test", item) as IDisposable)?.Dispose(); + Assert.That(item.IsDisposed, Is.True); + } + + // A SuperView rebuild (every click in its tag list) makes new render items, and each one decoded its + // textures again: about half a second per effect, measured on the firebox. The editor's cache loads a + // path once and owns what it loads; a render item disposes only its own objects. + [Test] + public void TextureCache_RemembersAPathThatDidNotLoad() + { + using var cache = NoTextures(); + using var item = new ParticleRenderItem(OneParticle("broken"), cache, () => Shader); + Assert.That(cache.Failed("broken"), Is.False, "not tried yet"); + Render(() => item.Draw(_device, View, RenderingTechnique.Normal)); + Assert.That((cache.Failed("broken"), cache.Failed("other")), Is.EqualTo((true, false))); + } + + // A texture replaced in a pack: the cache lets go of the old one, by either separator, and loads the new one. + [Test] + public void TextureCache_ForgetsAChangedPath() + { + var loads = 0; + var first = new Texture2D(_device, 1, 1); + using var cache = new VfxTextureCache((_, _) => ++loads == 1 ? first : new Texture2D(_device, 1, 1)); + cache.Load(_device, @"vfx\textures\a.dds"); + cache.Forget("vfx/textures/A.dds"); + Assert.That(first.IsDisposed, Is.True); + Assert.That(cache.Load(_device, @"vfx\textures\a.dds"), Is.Not.SameAs(first)); + Assert.That(loads, Is.EqualTo(2)); + } + + [Test] + public void TextureCache_LoadsEachPathOnce_AndOwnsWhatItLoads() + { + var loads = 0; + var texture = new Texture2D(_device, 1, 1); + var cache = new VfxTextureCache((_, _) => { loads++; return texture; }); + for (var i = 0; i < 2; i++) + { + using var item = new ParticleRenderItem(OneParticle("t"), cache, () => Shader); + Render(() => item.Draw(_device, View, RenderingTechnique.Normal)); + } + Assert.Multiple(() => + { + Assert.That(loads, Is.EqualTo(1), "loaded once for both render items"); + Assert.That(texture.IsDisposed, Is.False, "a render item leaves the cache's texture alone"); + }); + cache.Dispose(); + Assert.That(texture.IsDisposed, Is.True); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/ParticleSimulatorTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/ParticleSimulatorTests.cs new file mode 100644 index 000000000..4374f679e --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/ParticleSimulatorTests.cs @@ -0,0 +1,631 @@ +using System.Globalization; +using GameWorld.Core.Vfx; +using Microsoft.Xna.Framework; +using Shared.GameFormats.Vfx; + +namespace GameWorld.Core.Test.Vfx +{ + public class ParticleSimulatorTests + { + static VfxCurve C(params float[] values) => VfxCurve.Constant(values); + + // From one value at t = 0 to another at t = 1, linear. + static VfxCurve Ramp(float from, float to) => VfxCurve.Parse(string.Create(CultureInfo.InvariantCulture, + $"x_min,0,0;0.0,{from},linear,linear,0,0,0,0,1;1.0,{to},linear,linear,0,0,0,0,1|x_max,0,0;0.0,{from},linear,linear,0,0,0,0,1;1.0,{to},linear,linear,0,0,0,0,1:x,0;0,1")); + + // A random point between two constants, drawn per particle. + static VfxCurve Between(float min, float max) => VfxCurve.Parse(string.Create(CultureInfo.InvariantCulture, + $"x_min,0,0;0.0,{min},linear,linear,0,0,0,0,1|x_max,0,0;0.0,{max},linear,linear,0,0,0,0,1:x,1;0,1")); + + // Ten particles a second that live 100 s and do not move, from an emitter that never stops. + static EmitterSettings Steady(Action change = null) + { + var s = new EmitterSettings { Name = "test", InfiniteLife = true, Rate = C(10), ParticleLife = C(100), Spread = (0, 0) }; + change?.Invoke(s); + return s; + } + + static ParticleSimulator Run(EmitterSettings s, float time, Func effectWorldAt = null, float spawnEnd = float.PositiveInfinity, int seed = 1, Action log = null) + { + var simulator = new ParticleSimulator([s], seed, effectWorldAt ?? (_ => Matrix.Identity), spawnEnd, log); + simulator.AdvanceTo(time); + return simulator; + } + + static int Count(ParticleSimulator simulator) => simulator.Emitters.Sum(x => x.Particles.Count); + static List States(ParticleSimulator simulator) => simulator.Emitters.SelectMany(ParticleSimulator.States).ToList(); + + // A modder's rate past 2^25 particles a step made "accumulator -= 1" a no-op, so the spawn loop never ended. The cap + // holds over an emitter's own MaximumParticles too. + [Test] + public void HugeEmissionRate_StopsAtTheCap_AndReturns() + { + var run = Task.Run(() => Run(Steady(x => { x.Rate = C(1e12f); x.MaxParticles = 1_000_000; }), 0.5f)); + Assert.That(run.Wait(TimeSpan.FromSeconds(20)), Is.True, "AdvanceTo returned"); + Assert.That(Count(run.Result), Is.EqualTo(ParticleSimulator.ParticleCap)); + } + + // A NaN lifetime bias (a malformed override attribute) passed "life <= 0", and the particle never died. + [Test] + public void NaNLifetimeBias_SpawnsNothing() => Assert.That(Count(Run(Steady(x => x.LifeBias = (float.NaN, float.NaN)), 1)), Is.Zero); + + // The bone turns a quarter turn about Y and walks 5 m along X in the first second. + static Matrix MovingBone(float t) => Matrix.CreateRotationY(t * MathHelper.PiOver2) * Matrix.CreateTranslation(5 * t, 0, 0); + + [Test] + public void SameSeed_SameParticles_OtherSeed_Others() + { + EmitterSettings Spray() => Steady(s => { s.SpawnAngle = new Vector2(0, 360); s.Spread = (10, 80); s.InitialSpeed = Between(1, 5); }); + var a = States(Run(Spray(), 2)); + Assert.That(a, Is.Not.Empty); + Assert.That(States(Run(Spray(), 2)), Is.EqualTo(a)); + Assert.That(States(Run(Spray(), 2, seed: 2)), Is.Not.EqualTo(a)); + } + + [Test] + public void SpawnedCount_IsTheIntegralOfTheRateCurve() + { + // 10 a second rising to 30 over the emitter's 1 s life: 20 in all. + var s = Steady(x => { x.InfiniteLife = false; x.LifeTime = (1, 1); x.Rate = Ramp(10, 30); }); + Assert.That(Count(Run(s, 2)), Is.EqualTo(20).Within(1)); + } + + // A reference's spawn_rate_scale_factor multiplies the rate: 10 a second at 2.5 is 25 in a second. + [Test] + public void RateScale_MultipliesTheRate() => Assert.That(Count(Run(Steady(x => x.RateScale = (2.5f, 2.5f)), 1)), Is.EqualTo(25).Within(1)); + + [Test] + public void NegativeStartTime_PreWarms() + { + var s = Steady(x => x.StartTime = (-5, -5)); + Assert.That(Count(Run(s, -0.5f)), Is.Zero, "the effect has not started"); + Assert.That(Count(Run(s, 0)), Is.EqualTo(50).Within(1), "5 s of 10 a second, already there at the start"); + } + + [Test] + public void PreWarm_IsCappedAt30Seconds() + { + var s = Steady(x => { x.StartTime = (-1000, -1000); x.ParticleLife = C(10000); }); + Assert.That(Count(Run(s, 0)), Is.EqualTo(300).Within(1)); + } + + [Test] + public void ParticlesDie_AtTheirLifetime() + { + var simulator = Run(Steady(x => x.ParticleLife = C(0.5f)), 3); + Assert.That(Count(simulator), Is.EqualTo(5).Within(1), "10 a second, living half a second"); + Assert.That(simulator.Emitters[0].Particles.All(x => x.Age < x.Life)); + } + + [Test] + public void ZeroParticleLife_SpawnsNothing() => Assert.That(Count(Run(Steady(x => x.ParticleLife = C(0)), 1)), Is.Zero); + + // SkipParticlePositionUpdate (651 vanilla emitters, the nuln rifle's head among them): a particle stays where it + // spawned and keeps its velocity, which still aligns and stretches it. + [TestCase(false, true)] + [TestCase(true, false)] + public void SkipPositionUpdate_KeepsParticlesWhereTheySpawned(bool skip, bool moved) + { + var states = States(Run(Steady(x => { x.InitialSpeed = C(5); x.SkipPositionUpdate = skip; }), 1)); + Assert.That(states, Has.Count.GreaterThan(5)); + Assert.That(states.Any(x => x.Position.Length() > 1), Is.EqualTo(moved)); + Assert.That(states.All(x => x.Velocity.Length() > 4.9f), Is.True); + } + + // SpawnSpacing: one particle per that many metres moved, laid evenly along the path, on top of the rate. At 150 m/s + // a step moves 2.5 m, so particles bunched at each step's end would show as gaps of 2 and 3. + [Test] + public void SpawnSpacing_LaysOneParticlePerSpacingAlongThePath() + { + var states = States(Run(Steady(x => { x.Rate = C(0); x.SpawnSpacing = 1; }), 1, t => Matrix.CreateTranslation(150 * t, 0, 0))); + var xs = states.Select(x => x.Position.X).OrderBy(x => x).ToList(); + Assert.That(xs.Count, Is.EqualTo(150).Within(2)); + var gaps = xs.Zip(xs.Skip(1), (a, b) => b - a).ToList(); + Assert.That(gaps.Max() - gaps.Min(), Is.LessThan(0.01f), string.Join(", ", gaps.Take(6))); + } + + // A trail joins its particles in the order they come out, so that order is the order they were spawned in. + [Test] + public void SpawnSpacing_ParticlesComeOutOldestFirst() + { + var xs = States(Run(Steady(x => { x.Rate = C(0); x.SpawnSpacing = 1; }), 1, t => Matrix.CreateTranslation(150 * t, 0, 0))).Select(x => x.Position.X).ToList(); + Assert.That(xs, Is.Ordered.Ascending); + } + + // 334 vanilla SpawnSpacing emitters run at rate 0, so the distance spawner meets the cap on its own. + [Test] + public void SpawnSpacing_ParticleCap_IsLoggedOnce() + { + var log = new List(); + var simulator = Run(Steady(x => { x.Rate = C(0); x.SpawnSpacing = 0.01f; }), 0.5f, t => Matrix.CreateTranslation(150 * t, 0, 0), log: log.Add); + Assert.That((Count(simulator), log.Count), Is.EqualTo((ParticleSimulator.ParticleCap, 1))); + } + + // A particle laid back along the step is as old as the time since the emitter passed there, and has moved for it. + [Test] + public void SpawnSpacing_ParticlesHaveMovedForTheirAge() + { + var s = Steady(x => { x.Rate = C(0); x.SpawnSpacing = 1; x.InitialSpeed = C(2); x.Spread = (90, 90); }); + var particles = Run(s, 0.5f, t => Matrix.CreateTranslation(150 * t, 0, 0)).Emitters[0].Particles; + Assert.That(particles.Max(p => MathF.Abs(p.Position.Y - 2 * p.Age)), Is.LessThan(1e-3f)); + } + + // SpawnInheritedSpeed hands a world-space particle that share of the emitter's velocity; a local-space one + // already moves with its emitter. + [TestCase(SimulationSpace.World, 5f)] + [TestCase(SimulationSpace.LocalPositionOnly, 0f)] + public void InheritedSpeed_IsAShareOfTheEmittersVelocity(SimulationSpace space, float expected) + { + var s = Steady(x => { x.Space = space; x.InheritedSpeed = 0.5f; }); + var velocities = Run(s, 1, t => Matrix.CreateTranslation(10 * t, 0, 0)).Emitters[0].Particles.Select(p => p.Velocity).ToList(); + Assert.That(velocities, Is.Not.Empty); + Assert.That(velocities.Max(v => Vector3.Distance(v, new Vector3(expected, 0, 0))), Is.LessThan(1e-3f)); + } + + // SpawnInheritedDirectionBlend turns a particle's spawn direction that share of the way toward the way its emitter is + // moving: straight up, half way toward +X, is 45 degrees, in every space. A still emitter changes nothing. + [TestCase(SimulationSpace.World)] + [TestCase(SimulationSpace.LocalPositionOnly)] + [TestCase(SimulationSpace.LocalPositionRotation)] + public void InheritedDirectionBlend_TurnsTheSpawnTowardTheEmittersMotion(SimulationSpace space) + { + var s = Steady(x => { x.Space = space; x.InitialSpeed = C(1); x.Spread = (90, 90); x.InheritedDirectionBlend = 0.5f; }); + var moving = Run(s, 1, t => Matrix.CreateTranslation(10 * t, 0, 0)).Emitters[0].Particles.Select(p => p.Velocity).ToList(); + var still = Run(s, 1).Emitters[0].Particles.Select(p => p.Velocity).ToList(); + Assert.That(moving, Is.Not.Empty); + Assert.Multiple(() => + { + Assert.That(moving.Max(v => Vector3.Distance(v, Vector3.Normalize(new Vector3(1, 1, 0)))), Is.LessThan(1e-3f), "half way to +X"); + Assert.That(still.Max(v => Vector3.Distance(v, Vector3.UnitY)), Is.LessThan(1e-3f), "still"); + }); + } + + // ParticleRelativeSpawnDirection RELATIVE_TO_TERRAIN_NORMAL: the spawn direction is taken on the ground, the preview's flat + // one, keeping only the emitter's heading, as RigidFrame stands a rigid model there; without it the direction turns with the + // emitter. An emitter rolled 30 degrees and turned 90: up is (0, 0.866, 0.5) on it and straight up on the ground; +X is + // (0, 0.5, -0.866) on it and -Z, its heading, on the ground. + [TestCase(false, 90f, 0f, 0.866f, 0.5f)] + [TestCase(true, 90f, 0f, 1f, 0f)] + [TestCase(false, 0f, 0f, 0.5f, -0.866f)] + [TestCase(true, 0f, 0f, 0f, -1f)] + public void SpawnDirection_OnTerrain_KeepsOnlyTheEmittersHeading(bool onTerrain, float elevation, float x, float y, float z) + { + var s = Steady(e => { e.MaxParticles = 1; e.InitialSpeed = C(1); e.SpawnAngle = Vector2.Zero; e.Spread = (elevation, elevation); e.DirectionOnTerrain = onTerrain; }); + var tilted = Matrix.CreateRotationZ(MathHelper.ToRadians(30)) * Matrix.CreateRotationY(MathHelper.PiOver2); + var velocity = Run(s, 1, _ => tilted).Emitters[0].Particles.Single().Velocity; + Assert.That(Vector3.Distance(velocity, new Vector3(x, y, z)), Is.LessThan(1e-3f), velocity.ToString()); + } + + static List Children(ParticleSimulator simulator) => simulator.AllEmitters.Where(x => x.Settings.Name == "child").ToList(); + + // OnParticleSpawn: each particle starts its own copy of the event's effect, which follows it (track_position): a still + // child emitter's newest particle, a step old, is where its parent is now. + [Test] + public void SpawnEvent_StartsAChildEffectThatFollowsItsParticle() + { + var child = Steady(x => { x.Name = "child"; x.Rate = C(60); }); + var parent = Steady(x => { x.MaxParticles = 1; x.InitialSpeed = C(1); x.Spread = (90, 90); x.Events = [new ParticleEvent(false, [child], TrackPosition: true)]; }); + var simulator = Run(parent, 1); + var children = Children(simulator); + var at = ParticleSimulator.States(simulator.Emitters[0]).Single().Position.Y; + Assert.That(children, Has.Count.EqualTo(1)); + Assert.That(at, Is.GreaterThan(0.5f), "the parent rose"); + Assert.That(ParticleSimulator.States(children[0]).Last().Position.Y, Is.EqualTo(at).Within(0.03)); + } + + // OnParticleDeath: a particle that dies starts the event's effect where it died, 1 m up after half a second at 2 m/s. + [Test] + public void DeathEvent_StartsTheChildEffectWhereTheParticleDied() + { + var child = Steady(x => x.Name = "child"); + var parent = Steady(x => { x.MaxParticles = 1; x.ParticleLife = C(0.5f); x.InitialSpeed = C(2); x.Spread = (90, 90); x.Events = [new ParticleEvent(true, [child])]; }); + var children = Children(Run(parent, 1, spawnEnd: 0.1f)); + Assert.That(children, Has.Count.EqualTo(1)); + Assert.That(ParticleSimulator.States(children[0]).Select(p => p.Position.Y), Is.Not.Empty.And.All.EqualTo(1).Within(0.05)); + } + + // track_lifetime: when its particle dies the child effect stops spawning and ends with its own particles; without it, + // an infinite child goes on. + [TestCase(true, 0)] + [TestCase(false, 1)] + public void TrackLifetime_EndsTheChildWithItsParticle(bool track, int left) + { + var child = Steady(x => { x.Name = "child"; x.ParticleLife = C(0.2f); }); + var parent = Steady(x => { x.MaxParticles = 1; x.ParticleLife = C(0.5f); x.Events = [new ParticleEvent(false, [child], TrackLifetime: track)]; }); + Assert.That(Children(Run(parent, 1, spawnEnd: 0.1f)).Count(x => x.Particles.Count > 0), Is.EqualTo(left)); + } + + // track_colour and track_alpha: the child's particles take their parent's colour and alpha. + [TestCase(true, 0f, 0.5f)] + [TestCase(false, 1f, 1f)] + public void TrackColourAndAlpha_TintTheChildByItsParticle(bool track, float green, float alpha) + { + var child = Steady(x => x.Name = "child"); + var parent = Steady(x => { x.MaxParticles = 1; x.Colour = C(1, 0, 0); x.InitialAlpha = C(0.5f); x.Events = [new ParticleEvent(false, [child], TrackColour: track, TrackAlpha: track)]; }); + var state = ParticleSimulator.States(Children(Run(parent, 1)).Single()).Last(); + Assert.That((state.Colour.Y, state.Alpha), Is.EqualTo((green, alpha))); + } + + // A child effect per particle is a whole simulation: past 64 live on one emitter the rest do not start, and the preview + // says so once. + [Test] + public void ChildEffects_AreCappedPerEmitter_AndSaySo() + { + var log = new List(); + var child = Steady(x => x.Name = "child"); + var parent = Steady(x => { x.Rate = C(600); x.Events = [new ParticleEvent(false, [child])]; }); + var children = Children(Run(parent, 1, log: log.Add)); + Assert.That((children.Count, log.Count(x => x.Contains("64"))), Is.EqualTo((64, 1))); + } + + // ParticlesSpawnOnTerrain: the preview's terrain is the flat ground, y = 0 in the effect's space, whatever space + // the particle lives in. + [TestCase(SimulationSpace.World)] + [TestCase(SimulationSpace.LocalPositionOnly)] + [TestCase(SimulationSpace.LocalPositionRotation)] + public void SpawnOnTerrain_PutsParticlesOnTheGround(SimulationSpace space) + { + var s = Steady(x => { x.Space = space; x.SpawnOnTerrain = true; }); + var states = States(Run(s, 1, _ => Matrix.CreateRotationZ(0.3f) * Matrix.CreateTranslation(2, 5, 0))); + Assert.That(states, Is.Not.Empty); + Assert.That(states.Max(p => Math.Abs(p.Position.Y)), Is.LessThan(1e-3f)); + } + + // EnableCollisions: a particle that reaches the ground leaves it with its fall scaled by the bounce and its + // slide by one minus the damping, wherever it lives. + [TestCase(SimulationSpace.World)] + [TestCase(SimulationSpace.LocalPositionOnly)] + [TestCase(SimulationSpace.LocalPositionRotation)] + public void Collisions_BounceOffTheGround(SimulationSpace space) + { + var s = Steady(x => + { + x.Space = space; + x.SpawnAngle = Vector2.Zero; + x.Spread = (-45, -45); + x.InitialSpeed = C(10); + x.Collisions = true; + x.Bounce = (0.5f, 0.5f); + x.Damping = (0.5f, 0.5f); + }); + var states = States(Run(s, 0.5f, _ => Matrix.CreateTranslation(0, 1, 0))); + var c = 10 / MathF.Sqrt(2); + var falling = new Vector3(c, -c, 0); + var bounced = new Vector3(c / 2, c / 2, 0); + Assert.That(states.Min(p => p.Position.Y), Is.GreaterThan(-1e-3f)); + Assert.That(states.Count(p => Vector3.Distance(p.Velocity, bounced) < 1e-3f), Is.GreaterThan(0)); + Assert.That(states.All(p => Vector3.Distance(p.Velocity, bounced) < 1e-3f || Vector3.Distance(p.Velocity, falling) < 1e-3f), Is.True); + } + + // vs_flip_uv: each particle's texture is mirrored with the chain's chance, per axis. + [Test] + public void FlipUv_MirrorsParticlesByChance() + { + var states = States(Run(Steady(x => { x.FlipU = 1; x.FlipV = 0.5f; }), 10)); + Assert.That(states.All(p => p.FlipU), Is.True); + Assert.That(states.Count(p => p.FlipV), Is.InRange(states.Count / 4, states.Count * 3 / 4)); + } + + // A rigid particle turns on every axis: X and Y drawn in their ranges and spun on by their own signs; with two models, + // each particle picks one. + [Test] + public void RigidParticles_TurnOnEveryAxis_AndPickAModel() + { + var rigid = Steady(x => + { + x.Rigid = [new("a.wsmodel", new Vector3(0.5f), null), new("b.wsmodel", new Vector3(0.5f), null)]; + x.TurnXy = (new Vector2(10, 20), new Vector2(10, 20)); + x.SpinXy = (new Vector2(90, 0), new Vector2(90, 0)); + x.SpinSignXy = new Vector2(-1, 1); + }); + var simulator = Run(rigid, 1); + var pairs = simulator.Emitters[0].Particles.Zip(States(simulator)).ToList(); + Assert.Multiple(() => + { + Assert.That(pairs.Select(x => x.Second.TurnXy.X - MathHelper.ToRadians(10 - 90 * x.First.Age)), Is.All.EqualTo(0).Within(1e-4)); + Assert.That(pairs.Select(x => x.Second.TurnXy.Y), Is.All.EqualTo(MathHelper.ToRadians(20)).Within(1e-5)); + Assert.That(pairs.Select(x => x.Second.Model).Distinct().Order(), Is.EqualTo(new[] { 0, 1 })); + }); + } + + // vs_translation's restriction 3 turns a quad as a 3D object, so like a rigid particle it draws its X and Y turn. + [Test] + public void Object3DQuads_TurnOnEveryAxis() + { + var swipe = Steady(x => { x.Orientation = QuadOrientation.Object3D; x.TurnXy = (new Vector2(90, 20), new Vector2(90, 20)); }); + var turned = new Vector2(MathHelper.ToRadians(90), MathHelper.ToRadians(20)); + Assert.That(States(Run(swipe, 1)).Select(x => Vector2.Distance(x.TurnXy, turned)), Is.Not.Empty.And.All.LessThan(1e-5f)); + } + + // Minutes into a clock a float time cannot hold a 1e-5 s tolerance. Asked for the same time twice, the simulator must + // not start again from nothing: a paused persistent effect did, every frame. + [Test] + public void TheSameTimeTwice_DoesNotStartAgain_MinutesIn() + { + var simulator = new ParticleSimulator([Steady(x => x.Rate = C(0))], 1, _ => Matrix.Identity); + var restarts = 0; + for (var t = 250f; t < 400f; t += 0.0173f) + { + simulator.AdvanceTo(t); + var emitter = simulator.Emitters[0]; + simulator.AdvanceTo(t); + restarts += ReferenceEquals(emitter, simulator.Emitters[0]) ? 0 : 1; + } + Assert.That(restarts, Is.Zero); + } + + // A vector field volume: x along a row, y down the rows, z through the slices, sampled between cell centres and + // repeating outside itself. + [Test] + public void VectorField_SamplesBetweenCellCentres_AndRepeats() + { + var field = VfxVectorField.Read(VfxTestFiles.Field(2, 2, 2, (x, y, z) => new Vector3(x, y * 2, z * 4)))!; + Assert.Multiple(() => + { + Assert.That(field.Sample(new Vector3(0.75f, 0.25f, 0.75f)), Is.EqualTo(new Vector3(1, 0, 4)), "cell (1, 0, 1)"); + Assert.That(field.Sample(new Vector3(0.5f, 0.75f, 0.25f)), Is.EqualTo(new Vector3(0.5f, 2, 0)), "half way along x"); + Assert.That(field.Sample(new Vector3(1.75f, -0.25f, 0.75f)), Is.EqualTo(new Vector3(1, 2, 4)), "a volume over, either way"); + Assert.That(VfxVectorField.Read(VfxTestFiles.Xml()), Is.Null, "not a field"); + }); + } + + // VectorField*: where a particle is, the field times Strength pushes it (m/s2), turned by Orientation as an emitter + // is; Tightness pulls its velocity to that same vector. + [Test] + public void VectorField_PushesParticles_AndTightnessPullsTheirVelocity() + { + var field = VfxVectorField.Read(VfxTestFiles.Field(2, 2, 2, (_, _, _) => Vector3.UnitX))!; + EmitterSettings Fielded(float tightness) => Steady(x => + (x.Field, x.FieldSize, x.FieldStrength, x.FieldTightness, x.FieldOrientation) = (field, C(4), C(2), C(tightness), C(0, 90, 0))); + var turned = Vector3.TransformNormal(Vector3.UnitX, VfxConventions.Rotation(new Vector3(0, 90, 0))); + var loose = Run(Fielded(0), 1).Emitters[0].Particles; + var tight = Run(Fielded(1), 1).Emitters[0].Particles; + Assert.Multiple(() => + { + Assert.That(loose.Select(p => Vector3.Distance(p.Velocity, turned * 2 * p.Age)), Is.All.LessThan(1e-3)); + Assert.That(tight.Where(p => p.Age > 0).Select(p => Vector3.Distance(p.Velocity, turned * 2)), Is.All.LessThan(1e-4)); + }); + } + + // The field rides its emitter: Position moves it in the emitter's frame, so an emitter carried 11 m still finds its + // particles in the field's second half. Neither the offset nor the ride alone puts them there. + [Test] + public void VectorField_IsPlacedInTheEmittersFrame() + { + var field = VfxVectorField.Read(VfxTestFiles.Field(4, 1, 1, (x, _, _) => new Vector3(x >= 2 ? 1 : 0, 0, 0)))!; + var s = Steady(x => (x.Field, x.FieldSize, x.FieldStrength, x.FieldPosition) = (field, C(4), C(1), C(-0.5f, 0, 0))); + var particles = Run(s, 1, _ => Matrix.CreateTranslation(11, 0, 0)).Emitters[0].Particles; + Assert.That(particles.Select(p => p.Velocity.X - p.Age), Is.All.EqualTo(0).Within(1e-3)); + } + + // Vectorfield{Size,Position,Orientation}LoopAnim wrap their curves over the emitter's life, as EmitterPositionLoopAnim and + // EmitterOrientationLoopAnim wrap the emitter's own; unset, they hold at the end. An emitter living 1 s spawns on, and the + // particles born after it are pushed up only while the curve is wrapped back to where it pushes: Size 4 to 0 (none at the + // end), Position -1 to 1 m across a field pushing only in its upper half (out of it at the end), Orientation 0 to 90 + // degrees about X (pushing sideways at the end). + [TestCase("size", false)] + [TestCase("size", true)] + [TestCase("position", false)] + [TestCase("position", true)] + [TestCase("orientation", false)] + [TestCase("orientation", true)] + public void VectorFieldCurves_LoopOverTheEmittersLife(string curve, bool loop) + { + var uniform = VfxVectorField.Read(VfxTestFiles.Field(2, 2, 2, (_, _, _) => Vector3.UnitY))!; + var half = VfxVectorField.Read(VfxTestFiles.Field(4, 1, 1, (x, _, _) => new Vector3(0, x >= 2 ? 1 : 0, 0)))!; + var s = Steady(x => + { + (x.LifeTime, x.Field, x.FieldStrength, x.FieldSize) = ((1, 1), curve == "position" ? half : uniform, C(1), C(4)); + if (curve == "size") + (x.FieldSize, x.LoopFieldSize) = (Ramp(4, 0), loop); + if (curve == "position") + (x.FieldPosition, x.LoopFieldPosition) = (Ramp(-1, 1), loop); + if (curve == "orientation") + (x.FieldOrientation, x.LoopFieldOrientation) = (Ramp(0, 90), loop); + }); + var late = Run(s, 1.9f).Emitters[0].Particles.Where(p => p.Age is > 0.1f and < 0.8f).ToList(); + Assert.That(late, Is.Not.Empty); + Assert.That(late.Max(p => p.Velocity.Y / p.Age), loop ? Is.GreaterThan(0.05f) : Is.LessThan(0.01f)); + } + + // MaximumVelocity caps a particle's speed: all 31 vanilla uses are 1 m/s on particles launched still and carried by a + // force or a vector field, which would otherwise speed them up for as long as they live. A push of 5 m/s2 for a second + // stops at the cap, along its way; one of 0.5 m/s2 never reaches it and is left alone. + [TestCase(500f, 1f, true)] + [TestCase(50f, 1f, false)] + [TestCase(500f, 0f, false)] + public void MaximumVelocity_CapsTheSpeed(float force, float cap, bool capped) + { + var s = Steady(x => { x.MaxParticles = 1; x.Forces = [C(0.6f * force, 0.8f * force, 0)]; x.MaxSpeed = cap; }); + var p = Run(s, 1).Emitters[0].Particles.Single(); + Assert.That(p.Velocity.Length(), Is.EqualTo(capped ? cap : force * VfxConventions.ForceToMetres * p.Age).Within(1e-3)); + } + + // ps_alpha_crush: each particle carries its crush level, off the curve over its life. + [Test] + public void AlphaCrush_IsCarriedByEachParticle() + { + var states = States(Run(Steady(x => x.AlphaCrush = C(0.3f)), 1)); + Assert.That(states.Select(p => p.Crush).Distinct(), Is.EqualTo(new[] { 0.3f })); + } + + // curve_colour's fourth output, brightness, and ps_emissive's emissive_mul both multiply the colour, past 1. + [Test] + public void Brightness_AndEmissive_MultiplyTheColour() + { + var curve = VfxCurve.Parse("half,1,1;0.0,0.5,linear,linear,0,0,0,0,0|four,1,1;0.0,4.0,linear,linear,0,0,0,0,0:r,0;0,0|g,0;0,0|b,0;0,0|brightness,0;1,1"); + var states = States(Run(Steady(x => { x.Colour = curve; x.EmissiveMul = 3; }), 1)); + Assert.That(states.Select(p => p.Colour).Distinct(), Is.EqualTo(new[] { new Vector3(6) })); + } + + [Test] + public void SpawnSpacing_StandingStill_SpawnsOnlyByRate() + { + Assert.That(Count(Run(Steady(x => { x.Rate = C(0); x.SpawnSpacing = 1; }), 1)), Is.Zero); + Assert.That(Count(Run(Steady(x => x.SpawnSpacing = 1), 1)), Is.EqualTo(10).Within(1)); + } + + [Test] + public void ZeroEmitterLifeTime_GivesFiniteStates() + { + var states = States(Run(Steady(x => { x.LifeTime = (0, 0); x.LoopRate = true; x.InitialSpeed = C(1); }), 1)); + Assert.That(states, Is.Not.Empty); + Assert.That(states.All(x => float.IsFinite(x.Position.X + x.Position.Y + x.Position.Z + x.Alpha + x.Size.X))); + } + + // Forces are cm/s2: vanilla's falling debris carries -981 (156 emitters), rocks -1471.5, sand -490.5 and + // -1962, against 101 uses of 9.81. Read as m/s2, Belegar's rising embers (+98.1..196.2) shot up at 20-40 m/s. + [Test] + public void ConstantForce_IsInCentimetresPerSecondSquared_AndGivesTheAnalyticPath() + { + // One particle, 2 m/s along X, under one g downward: -981 cm/s2, 9.81 m/s2. + var s = Steady(x => { x.MaxParticles = 1; x.InitialSpeed = C(2); x.SpawnAngle = Vector2.Zero; x.Forces = [C(0, -981, 0)]; }); + var p = Run(s, 1).Emitters[0].Particles.Single(); + Assert.That(Vector3.Distance(p.Position, new Vector3(2 * p.Age, -4.905f * p.Age * p.Age, 0)), Is.LessThan(1e-3f)); + } + + [TestCase(SimulationSpace.World, 0f, 0f, 1f, Description = "stays where it spawned")] + [TestCase(SimulationSpace.LocalPositionRotation, 6f, 0f, 0f, Description = "moves and turns with the bone")] + [TestCase(SimulationSpace.LocalPositionOnly, 5f, 0f, 1f, Description = "moves with the bone, does not turn")] + public void SimulationSpace_DecidesWhatTheBoneCarries(SimulationSpace space, float x, float y, float z) + { + // One still particle spawned 1 m along the emitter's Z: a ring of radius 1, at 90 degrees. + var s = Steady(e => { e.Space = space; e.MaxParticles = 1; e.Rate = C(600); e.SpawnRing = C(1, 1); e.SpawnArc = new Vector2(90, 90); }); + var state = States(Run(s, 1, MovingBone)).Single(); + Assert.That(Vector3.Distance(state.Position, new Vector3(x, y, z)), Is.LessThan(0.15f)); + } + + [Test] + public void ScrubBack_ThenForward_EqualsAStraightRun() + { + EmitterSettings Spray() => Steady(s => { s.SpawnAngle = new Vector2(0, 360); s.InitialSpeed = Between(1, 5); s.ParticleLife = Between(0.5f, 2); }); + var straight = Run(Spray(), 2, MovingBone); + var scrubbed = Run(Spray(), 3, MovingBone); + scrubbed.AdvanceTo(2); + Assert.That(States(scrubbed), Is.EqualTo(States(straight))); + } + + [Test] + public void TagEnd_StopsSpawning_AndTheLiveOnesLiveOn() => Assert.That(Count(Run(Steady(), 2, spawnEnd: 0.5f)), Is.EqualTo(5).Within(1)); + + [Test] + public void ParticleCap_Holds_AndIsLoggedOnce() + { + var log = new List(); + var simulator = Run(Steady(x => x.Rate = C(100000)), 0.5f, log: log.Add); + Assert.That(Count(simulator), Is.EqualTo(ParticleSimulator.ParticleCap)); + Assert.That(log, Has.Count.EqualTo(1)); + } + + [Test] + public void OverLife_SizeAndAlphaFollowTheirCurves() + { + // Spawned at size 2 and alpha 1, living 2 s; ParticleScale goes 1 to 3 and curve_alpha 1 to 0 over that life. + var s = Steady(x => { x.MaxParticles = 1; x.ParticleLife = C(2); x.InitialScale = C(2, 2, 2); x.Scale = Ramp(1, 3); x.Alpha = Ramp(1, 0); }); + var simulator = Run(s, 1); + var p = simulator.Emitters[0].Particles.Single(); + var state = States(simulator).Single(); + var life = p.Age / p.Life; + Assert.That(state.Size.X, Is.EqualTo(2 * (1 + 2 * life)).Within(1e-3)); + Assert.That(state.Alpha, Is.EqualTo(1 - life).Within(1e-3)); + } + + // vs_vertex_alpha's alpha offsets squeeze curve_alpha into the window from the start offset to 1 less the end offset, each + // lerped by the particle's alpha random, as CA's map_range has it, and hold its ends outside: frost_wyrm's end offset of + // 0.8 plays the whole curve in the first fifth of the life; ostankya's start of -0.3 stretches it past the birth. + [TestCase(0.1f, 0f, 0f, 0.8f, 0.8f, 0f, 0.5f)] + [TestCase(0.5f, 0f, 0f, 0.8f, 0.8f, 0f, 1f)] + [TestCase(0.5f, 0.3f, 0.3f, 0f, 0f, 0f, 0.2857f)] + [TestCase(0.2f, 0.3f, 0.3f, 0f, 0f, 0f, 0f)] + [TestCase(0.35f, -0.3f, -0.3f, 0f, 0f, 0f, 0.5f)] + [TestCase(0.6f, 0f, 0.4f, 0f, 0f, 0.5f, 0.5f)] + [TestCase(0.3f, 0f, 0f, 0.2f, 0.6f, 0.5f, 0.5f)] + public void AlphaLife_SqueezesTheCurveIntoItsWindow(float life, float startMin, float startMax, float endMin, float endMax, float random, float expected) => + Assert.That(VfxConventions.AlphaLife(life, new Vector2(startMin, startMax), new Vector2(endMin, endMax), random), Is.EqualTo(expected).Within(1e-3)); + + // ...on the particle: curve_alpha 1 to 0 with an end offset of 0.5 is gone by half its life. + [Test] + public void AlphaOffsets_ReachTheParticle() + { + var s = Steady(x => { x.MaxParticles = 1; x.ParticleLife = C(2); x.Alpha = Ramp(1, 0); x.AlphaEndOffset = new Vector2(0.5f, 0.5f); }); + var simulator = Run(s, 0.5f); + var p = simulator.Emitters[0].Particles.Single(); + Assert.That(States(simulator).Single().Alpha, Is.EqualTo(Math.Max(0, 1 - p.Age / p.Life / 0.5f)).Within(1e-3)); + } + + [TestCase(0.5f, 0f, 1f, false, true, 32)] + [TestCase(0.5f, 0f, 0.5f, false, true, 16)] + [TestCase(0f, 0.25f, 2f, true, true, 32, Description = "fixed speed: loops per second")] + [TestCase(0.75f, 0f, 2f, false, false, 63, Description = "no loop: holds the last cell")] + [TestCase(0.75f, 0f, 2f, false, true, 32, Description = "loop: wraps")] + public void FlipbookCell_OfSixtyFour(float lifeFraction, float age, float loops, bool fixedSpeed, bool loop, int expected) => + Assert.That(VfxConventions.FlipbookFrame(64, lifeFraction, age, loops, fixedSpeed, loop).Cell, Is.EqualTo(expected)); + + // no_loops runs from its start to its end over the particle's life, as CA's lerps it by the life fraction, the same for + // every particle: by life fraction u the strip has played u x lerp(start, end, u) times, or age x lerp(start, end, u) at a + // fixed speed. 1 to 3 over a 2 s life, read off 15 particles of different ages. + [TestCase(false)] + [TestCase(true)] + public void Flipbook_LoopsRunFromStartToEndOverLife(bool fixedSpeed) + { + var s = Steady(x => { x.ParticleLife = C(2); x.Animated = true; x.CellsX = 16; x.Loops = new Vector2(1, 3); x.FixedSpeed = fixedSpeed; }); + var emitter = Run(s, 1.5f).Emitters[0]; + var expected = emitter.Particles.Select(p => p.Age / p.Life).Select(v => (int)(Fraction((fixedSpeed ? v * 2 : v) * (1 + 2 * v)) * 16)); + Assert.That(ParticleSimulator.States(emitter).Select(x => x.Cell), Is.EqualTo(expected)); + static float Fraction(float x) => x - MathF.Floor(x); + } + + // cell_blend: a quarter of the way through cell 2 of 4 is a quarter of the way to cell 3; the last cell runs into the + // first when the strip loops and into itself when it holds. + [TestCase(0.5625f, true, 2, 3, 0.25f)] + [TestCase(0.875f, true, 3, 0, 0.5f)] + [TestCase(0.875f, false, 3, 3, 0.5f)] + public void FlipbookFrame_BlendsIntoTheNextCell(float lifeFraction, bool loop, int cell, int next, float blend) + { + var frame = VfxConventions.FlipbookFrame(4, lifeFraction, 0, 1, false, loop); + Assert.That((frame.Cell, frame.Next), Is.EqualTo((cell, next))); + Assert.That(frame.Blend, Is.EqualTo(blend).Within(1e-4)); + } + + // vs_animated_texture's rows on a 4 x 4 sheet, as CA's has them: row_restricted plays the 4 cells of row_restriction; + // randomize_row starts on the particle's random row, 0.6 of 4 rows being row 2, and without row_restricted plays the + // whole sheet from there, past the last cell round to the first. + [TestCase(false, 0, false, 0.6f, 0.5f, 8, 9, Description = "the whole sheet")] + [TestCase(true, 2, false, 0.6f, 0.5f, 10, 11, Description = "row 2 only")] + [TestCase(true, 2, false, 0.6f, 0.875f, 11, 8, Description = "row 2 loops inside the row")] + [TestCase(false, 0, true, 0.6f, 0.25f, 12, 13, Description = "from random row 2")] + [TestCase(true, 1, true, 0.6f, 0.5f, 10, 11, Description = "the random row overrides row_restriction")] + [TestCase(false, 0, true, 0.9f, 0.5f, 4, 5, Description = "from row 3, round past the last cell")] + public void FlipbookFrame_PlaysItsRow(bool restricted, int row, bool randomRow, float random, float lifeFraction, int cell, int next) + { + var s = new EmitterSettings { Animated = true, CellsX = 4, CellsY = 4, RowRestricted = restricted, Row = row, RandomRow = randomRow }; + var frame = VfxConventions.FlipbookFrame(s, lifeFraction, 0, 1, random); + Assert.That((frame.Cell, frame.Next), Is.EqualTo((cell, next))); + } + + // randomize_cell on a flipbook starts each particle that far along its strip, as CA's offset is its random_unary, before + // the strip wraps or holds: 0.5 of life and 0.3 is 0.8 of 16 cells; 0.75 and 0.5 wraps to 0.25 when looping and holds the + // last cell when not; with randomize_row too the one random picks the row and the start in it (0.6: row 2, 2.4 of 4). + // Two loops play the strip twice as fast from the same start: 0.25 of life is 0.5 of the strip, and 0.3 on is 0.8 again. + [TestCase(true, false, 0.5f, 1f, 0.3f, 12, 13, 0.8f)] + [TestCase(true, false, 0.75f, 1f, 0.5f, 4, 5, 0f)] + [TestCase(false, false, 0.75f, 1f, 0.5f, 15, 15, 1f)] + [TestCase(true, true, 0f, 1f, 0.6f, 10, 11, 0.4f)] + [TestCase(true, false, 0.25f, 2f, 0.3f, 12, 13, 0.8f)] + public void FlipbookFrame_StartsAtTheParticlesRandomCell(bool loop, bool randomRow, float lifeFraction, float loops, float random, int cell, int next, float blend) + { + var s = new EmitterSettings { Animated = true, CellsX = 4, CellsY = 4, RandomCell = true, FlipbookLoop = loop, RowRestricted = randomRow, RandomRow = randomRow }; + var frame = VfxConventions.FlipbookFrame(s, lifeFraction, 0, loops, random); + Assert.That((frame.Cell, frame.Next), Is.EqualTo((cell, next))); + Assert.That(frame.Blend, Is.EqualTo(blend).Within(2e-3)); + } + + // ...and each particle draws its own row: one cell per row, one row each, so the cells are the rows. + [Test] + public void FlipbookRows_ARandomRowPerParticle() + { + var s = Steady(x => { x.MaxParticles = 40; x.Animated = true; x.CellsX = 1; x.CellsY = 8; x.RowRestricted = true; x.RandomRow = true; }); + Assert.That(States(Run(s, 2)).Select(x => x.Cell).Distinct().Count(), Is.GreaterThan(4)); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VanillaPacks.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VanillaPacks.cs new file mode 100644 index 000000000..5cd77c7d2 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VanillaPacks.cs @@ -0,0 +1,25 @@ +using System.IO; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Utility; +using Test.TestingUtility.Shared; + +namespace GameWorld.Core.Test.Vfx +{ + // The game's own packs, from the data folder in WH3_DATA; the test is skipped without it. + static class VanillaPacks + { + public static (AssetEditorTestRunner Runner, List Containers) Load(params string[] packNames) + { + var dataFolder = Environment.GetEnvironmentVariable("WH3_DATA"); + Assume.That(dataFolder != null && Directory.Exists(dataFolder), "set WH3_DATA"); + + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + var loader = (IPackFileContainerLoader)runner.ServiceProvider.GetService(typeof(IPackFileContainerLoader))!; + var containers = packNames + .Select(x => runner.PackFileService.AddContainer(loader.CreateFromPackFile(PackFileContainerType.Normal, Path.Combine(dataFolder!, x), true))!) + .ToList(); + return (runner, containers); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VanillaVfxSurvey.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VanillaVfxSurvey.cs new file mode 100644 index 000000000..3bd45b7a1 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VanillaVfxSurvey.cs @@ -0,0 +1,83 @@ +using System.IO; +using GameWorld.Core.Vfx; +using Shared.GameFormats.Vfx; + +namespace GameWorld.Core.Test.Vfx +{ + // Needs the game installed (WH3_DATA). Parses every vanilla vfx file, resolves every effect and reads + // every emitter; prints the counts, the problems, the gaps and candidates for a set of effects to compare against CA's editor. + public class VanillaVfxSurvey + { + const string Firebox = "wh3_dlc23_warmachine_steam_engine_firebox"; + + [Test, Explicit] + public void Warhammer3_AllVanillaVfx_ParseResolveAndRead() + { + var (runner, containers) = VanillaPacks.Load("vfx_desc.pack"); + var resolver = new VfxResolver(runner.PackFileService); + var files = containers[0].GetAllFiles().Where(x => x.Key.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x.Key).ToList(); + + var unparsed = new List(); + var problems = new List(); + var gaps = new Dictionary(); + var emitters = 0; + var candidates = new Dictionary> + { + ["looping smoke"] = [], ["embers"] = [], ["burst"] = [], ["velocity-aligned sparks"] = [], ["fixed-speed flipbook"] = [], + }; + foreach (var (path, file) in files) + { + try { VfxFile.Parse(file.DataSource.ReadData()); } + catch (Exception e) { unparsed.Add($"{path}: {e.Message}"); continue; } + + if (path.Count(c => c == '\\' || c == '/') != 1) + continue; // only vfx/.xml is reachable by an EFFECT tag's name + var name = Path.GetFileNameWithoutExtension(path); + var resolved = resolver.Resolve(name, new Random(1)); + problems.AddRange(resolved.Problems.Select(x => $"{name}: {x}")); + var settings = resolved.Emitters.Select(EmitterSettings.From).ToList(); + emitters += settings.Count; + foreach (var gap in settings.SelectMany(x => x.Skipped)) + gaps[gap] = gaps.GetValueOrDefault(gap) + 1; + Classify(name, settings, candidates); + } + + TestContext.Out.WriteLine($"{files.Count} files; {emitters} enabled emitters resolved"); + TestContext.Out.WriteLine($"files that do not parse: {unparsed.Count}"); + foreach (var line in unparsed.Take(20)) TestContext.Out.WriteLine(" " + line); + TestContext.Out.WriteLine($"reference problems: {problems.Count}"); + foreach (var line in problems.Take(40)) TestContext.Out.WriteLine(" " + line); + TestContext.Out.WriteLine("gaps, by emitter count:"); + foreach (var (gap, count) in gaps.OrderByDescending(x => x.Value)) TestContext.Out.WriteLine($" {count,7} {gap}"); + foreach (var (category, names) in candidates) TestContext.Out.WriteLine($"{category}: {string.Join(", ", names)}"); + + var firebox = resolver.Resolve(Firebox, new Random(1)); + var fireboxFile = VfxFile.Parse(runner.PackFileService.FindFile(VfxResolver.PathOf(Firebox))!.DataSource.ReadData()); + // Two references: lib_warmachines (the furnace) and lib_lightsource (its orange light). + var switchedOn = fireboxFile.References.SelectMany(r => r.Overrides.Values).Where(x => x.Enable).Select(x => x.Name).ToList(); + TestContext.Out.WriteLine($"firebox: {string.Join(", ", firebox.Emitters.Select(x => x.Emitter.Id))}"); + Assert.Multiple(() => + { + Assert.That(unparsed, Is.Empty, "vanilla files that do not parse"); + Assert.That(firebox.Emitters.Select(x => x.Emitter.Id), Is.EquivalentTo(switchedOn), "the firebox draws exactly the emitters its reference switches on"); + }); + } + + static void Classify(string name, List settings, Dictionary> candidates) + { + var drawn = settings.Where(x => x.Draws).ToList(); + if (!name.StartsWith("wh3_") || drawn.Count == 0) + return; + void Add(string category, bool matches) + { + if (matches && candidates[category].Count < 3) + candidates[category].Add(name); + } + Add("looping smoke", drawn.Any(x => x.InfiniteLife && x.Premultiplied && x.AdditiveMul == 0 && x.Animated)); + Add("embers", drawn.Any(x => x.InfiniteLife && x.AdditiveMul >= 1 && x.Orientation == QuadOrientation.CameraFacing)); + Add("burst", drawn.All(x => !x.InfiniteLife && x.LifeTime.Max < 1)); + Add("velocity-aligned sparks", drawn.Any(x => x.Orientation == QuadOrientation.VelocityAligned && x.AdditiveMul >= 1)); + Add("fixed-speed flipbook", drawn.Any(x => x.Animated && x.FixedSpeed)); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFrameCost.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFrameCost.cs new file mode 100644 index 000000000..5efd86fc9 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFrameCost.cs @@ -0,0 +1,103 @@ +using System.Diagnostics; +using System.Globalization; +using GameWorld.Core.Components.Rendering; +using GameWorld.Core.Rendering; +using GameWorld.Core.Vfx; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Test.TestingUtility.Shared; + +namespace GameWorld.Core.Test.Vfx +{ + // What a frame of effects costs, split as the app spends it: stepping the simulators on, the clip's loop (a jump back, + // which starts every effect over), and drawing them in the main and the glow pass. Needs WH3_DATA. + // VFX_PREVIEW effect names, comma-separated, each run as many times as it is named (default: a Bale Taurus's) + // VFX_FRAMES frames timed (default 600, ten seconds) + // VFX_LOOP the clip's length in seconds, where it loops (default 2.2) + public class VfxFrameCost + { + static string Env(string name, string fallback) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : fallback; + + [Test, Explicit, Apartment(ApartmentState.STA)] + public void TimeAFrame() + { + var (runner, _) = VanillaPacks.Load("vfx_desc.pack", "vfx.pack"); + const string Legs = "wh3_dlc23_entity_bale_taurus_leg_fire"; + const string Wings = "wh3_dlc23_entity_bale_taurus_wing_fire"; + var names = Env("VFX_PREVIEW", string.Join(",", Enumerable.Repeat(Legs, 4).Concat(Enumerable.Repeat(Wings, 4)))) + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var frames = int.Parse(Env("VFX_FRAMES", "600"), CultureInfo.InvariantCulture); + var loop = float.Parse(Env("VFX_LOOP", "2.2"), CultureInfo.InvariantCulture); + + var service = new GraphicsDeviceServiceMock(); + var device = service.GraphicsDevice; + var log = new List(); + using var content = ParticleRenderItemTests.Content(service); + var shader = content.Load("Shaders/Particle"); + var textures = new VfxTextureCache(ParticleRenderItem.PackTextureLoader(runner.PackFileService, log.Add)); + using var target = new RenderTarget2D(device, 1280, 720, false, SurfaceFormat.Color, DepthFormat.Depth24, 0, RenderTargetUsage.PreserveContents); + var eye = new Vector3(6, 4, 6); + var parameters = new CommonShaderParameters(Matrix.CreateLookAt(eye, new Vector3(0, 1, 0), Vector3.Up), + Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, 16 / 9f, 0.1f, 500), eye, new Vector3(0, 1, 0), 0, 0, 0, 1, [], Vector3.One); + + var resolver = new VfxResolver(runner.PackFileService); + var simulators = names.Select((name, i) => new ParticleSimulator(resolver.Resolve(name, new Random(i)).Emitters.Select(EmitterSettings.From).ToList(), i, + t => Matrix.CreateTranslation(i % 4 - 1.5f, 1, i / 4 - 0.5f), log: log.Add)).ToList(); + var items = simulators.Select(x => new ParticleRenderItem(x, textures, () => shader)).ToList(); + + var step = new Stopwatch(); + var draw = new Stopwatch(); + var resets = new List(); + var worst = 0.0; + var particles = 0; + var lights = 0; + var pixel = new Color[1]; + for (var f = 0; f < frames; f++) + { + var time = f / 60f % loop; + var looped = f > 0 && time < (f - 1) / 60f % loop; + var started = step.Elapsed.TotalMilliseconds; + step.Start(); + foreach (var simulator in simulators) + simulator.AdvanceTo(time); + step.Stop(); + var spent = step.Elapsed.TotalMilliseconds - started; + if (looped) + resets.Add(spent); + else + worst = Math.Max(worst, spent); + + draw.Start(); + device.SetRenderTarget(target); + device.Clear(Color.Transparent); + device.DepthStencilState = DepthStencilState.DepthRead; + device.RasterizerState = RasterizerState.CullNone; + // The frame's lights, as the engine hands them every draw, and the ground light it draws once from them. + var scene = parameters with { PointLights = items.SelectMany(x => x.Lights()).Take(CommonShaderParameters.MaxPointLights).ToList() }; + lights = scene.PointLights.Count; + ParticleRenderItem.DrawGroundLight(device, shader, scene); + foreach (var item in items) + item.Draw(device, scene, RenderingTechnique.Normal); + foreach (var item in items) + item.Draw(device, scene, RenderingTechnique.Emissive); + device.SetRenderTarget(null); + // Waits for the GPU, so the draw time holds its work and not only the calls that queue it. + target.GetData(0, new Rectangle(0, 0, 1, 1), pixel, 0, 1); + draw.Stop(); + particles = simulators.Sum(x => x.AllEmitters.Sum(e => e.Particles.Count)); + } + + var steady = frames - resets.Count; + TestContext.Out.WriteLine($"{names.Length} effects, {particles} particles and {lights} lights at the end"); + TestContext.Out.WriteLine($"step: {(step.Elapsed.TotalMilliseconds - resets.Sum()) / steady:0.000} ms a frame, worst {worst:0.000}"); + TestContext.Out.WriteLine($"loop: {resets.Count} resets, {resets.DefaultIfEmpty().Average():0.000} ms each, worst {resets.DefaultIfEmpty().Max():0.000}"); + TestContext.Out.WriteLine($"draw, main and glow: {draw.Elapsed.TotalMilliseconds / frames:0.000} ms a frame"); + foreach (var line in log.Distinct().Take(20)) + TestContext.Out.WriteLine(line); + foreach (var item in items) + item.Dispose(); + textures.Dispose(); + service.Release(); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxPreviewRender.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxPreviewRender.cs new file mode 100644 index 000000000..40b661839 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxPreviewRender.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using System.IO; +using GameWorld.Core.Components.Rendering; +using GameWorld.Core.Rendering; +using GameWorld.Core.Vfx; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Test.TestingUtility.Shared; + +namespace GameWorld.Core.Test.Vfx +{ + // The calibration tool: renders effects alone at the origin over a 1 m grid, from the + // front, side and top, at the given times, to PNGs to set beside captures from CA's editor. Needs WH3_DATA. + // VFX_PREVIEW effect names, comma-separated (default: the steam engine firebox) + // VFX_TIMES seconds after the effect starts (default 0.25,0.5,1,2) + // VFX_PREVIEW_OUT output folder + // VFX_DISTANCE camera distance in metres (default 6) + // VFX_FLIGHT metres a second the effect flies along +X, 1 m up, passing x = 0 at each time (default 0) + // VFX_LOOSE a folder of loose vfx *.xml read in place of the packs' copies, so a mod's effect draws + public class VfxPreviewRender + { + static string Env(string name, string fallback) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : fallback; + + [Test, Explicit, Apartment(ApartmentState.STA)] + public void RenderEffects() + { + var (runner, _) = VanillaPacks.Load("vfx_desc.pack", "vfx.pack"); + var names = Env("VFX_PREVIEW", "wh3_dlc23_warmachine_steam_engine_firebox").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var times = Env("VFX_TIMES", "0.25,0.5,1,2").Split(',').Select(x => float.Parse(x, CultureInfo.InvariantCulture)).ToList(); + var output = Env("VFX_PREVIEW_OUT", Path.Combine(TestContext.CurrentContext.WorkDirectory, "vfx_preview")); + var distance = float.Parse(Env("VFX_DISTANCE", "6"), CultureInfo.InvariantCulture); + var flight = float.Parse(Env("VFX_FLIGHT", "0"), CultureInfo.InvariantCulture); + var loose = Env("VFX_LOOSE", "") is { Length: > 0 } folder + ? Directory.GetFiles(folder, "*.xml").ToDictionary(x => VfxResolver.Key(Path.GetFileNameWithoutExtension(x)), File.ReadAllBytes) + : []; + Directory.CreateDirectory(output); + + var service = new GraphicsDeviceServiceMock(); + var device = service.GraphicsDevice; + var log = new List(); + using var content = ParticleRenderItemTests.Content(service); + var shader = content.Load("Shaders/Particle"); + var textures = new VfxTextureCache(ParticleRenderItem.PackTextureLoader(runner.PackFileService, log.Add)); + using var target = new RenderTarget2D(device, 512, 512, false, SurfaceFormat.Color, DepthFormat.Depth24, 0, RenderTargetUsage.PreserveContents); + using var picture = new RenderTarget2D(device, 512, 512); + using var batch = new SpriteBatch(device); + using var lines = new BasicEffect(device) { VertexColorEnabled = true }; + var lookAt = new Vector3(0, 1, 0); + var views = new (string Name, Vector3 Eye, Vector3 Up)[] + { + ("front", new Vector3(0, 1, distance), Vector3.Up), + ("side", new Vector3(distance, 1, 0), Vector3.Up), + ("top", new Vector3(0, distance + 1, 0), Vector3.Forward), + }; + + foreach (var name in names) + { + var resolved = new VfxResolver(runner.PackFileService, loose).Resolve(name, new Random(1)); + Assert.That(resolved.Found, $"{name} is not in vfx_desc.pack"); + var settings = resolved.Emitters.Select(EmitterSettings.From).ToList(); + foreach (var time in times) + { + var simulator = new ParticleSimulator(settings, 1, t => Matrix.CreateTranslation(flight * (t - time), flight == 0 ? 0 : 1, 0), log: log.Add); + simulator.AdvanceTo(time); + using var item = new ParticleRenderItem(simulator, textures, () => shader); + foreach (var (view, eye, up) in views) + { + var parameters = new CommonShaderParameters(Matrix.CreateLookAt(eye, lookAt, up), + Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, 1, 0.1f, 500), eye, lookAt, 0, 0, 0, 1, [], Vector3.One) { PointLights = item.Lights().ToList() }; + device.SetRenderTarget(target); + device.Clear(Color.Transparent); + device.BlendState = BlendState.Opaque; + device.DepthStencilState = DepthStencilState.Default; + DrawGrid(device, lines, parameters); + device.DepthStencilState = DepthStencilState.DepthRead; + device.RasterizerState = RasterizerState.CullNone; + ParticleRenderItem.DrawGroundLight(device, shader, parameters); + item.Draw(device, parameters, RenderingTechnique.Normal); + // Laid over the background by its alpha, as the app lays its scene. + device.SetRenderTarget(picture); + device.Clear(new Color(40, 40, 40)); + batch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend); + batch.Draw(target, Vector2.Zero, Color.White); + batch.End(); + device.SetRenderTarget(null); + using var stream = File.Create(Path.Combine(output, $"{name}_{view}_{time.ToString("0.00", CultureInfo.InvariantCulture)}s.png")); + picture.SaveAsPng(stream, picture.Width, picture.Height); + } + } + TestContext.Out.WriteLine($"{name}: {settings.Count(x => x.Draws)} of {settings.Count} emitters drawn"); + foreach (var problem in resolved.Problems) + TestContext.Out.WriteLine(" " + problem); + foreach (var gap in settings.SelectMany(x => x.Skipped.Select(g => $" {x.Name}: {g}")).Distinct()) + TestContext.Out.WriteLine(gap); + } + foreach (var line in log.Distinct()) + TestContext.Out.WriteLine(line); + textures.Dispose(); + service.Release(); + } + + // A 1 m grid on the ground, 10 m across; the X axis red, the Z axis blue. + static void DrawGrid(GraphicsDevice device, BasicEffect effect, CommonShaderParameters parameters) + { + var vertices = new List(); + for (var i = -5; i <= 5; i++) + { + var alongX = i == 0 ? Color.Red : Color.Gray; + var alongZ = i == 0 ? Color.Blue : Color.Gray; + vertices.Add(new VertexPositionColor(new Vector3(-5, 0, i), alongX)); + vertices.Add(new VertexPositionColor(new Vector3(5, 0, i), alongX)); + vertices.Add(new VertexPositionColor(new Vector3(i, 0, -5), alongZ)); + vertices.Add(new VertexPositionColor(new Vector3(i, 0, 5), alongZ)); + } + effect.World = Matrix.Identity; + effect.View = parameters.View; + effect.Projection = parameters.Projection; + foreach (var pass in effect.CurrentTechnique.Passes) + { + pass.Apply(); + device.DrawUserPrimitives(PrimitiveType.LineList, vertices.ToArray(), 0, vertices.Count / 2); + } + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxResolverTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxResolverTests.cs new file mode 100644 index 000000000..b1d2e4eed --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxResolverTests.cs @@ -0,0 +1,143 @@ +using GameWorld.Core.Vfx; +using Microsoft.Xna.Framework; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using static GameWorld.Core.Test.Vfx.VfxTestFiles; + +namespace GameWorld.Core.Test.Vfx +{ + public class VfxResolverTests + { + static VfxResolveResult Resolve(IPackFileService pack, string name) => new VfxResolver(pack).Resolve(name, new Random(1)); + + [Test] + public void Unsaved_bytes_stand_in_for_the_packs_copy() + { + var packs = VfxTestFiles.Pack(("a.xml", VfxTestFiles.Xml(emitters: VfxTestFiles.Emitter("saved")))); + var unsaved = new Dictionary { [VfxResolver.Key("a")] = VfxTestFiles.Xml(emitters: VfxTestFiles.Emitter("edited")) }; + var result = new VfxResolver(packs, unsaved).Resolve("a", new Random(1)); + Assert.That(result.Emitters.Select(e => e.Emitter.Id), Is.EqualTo(new[] { "edited" })); + } + + [Test] + public void NestedReferences_ComposeTransforms_AndTheOuterOverrideWins() + { + var pack = Pack( + ("lib.xml", Xml(emitters: Emitter("a") + Emitter("b"))), + ("mid.xml", Xml(references: Reference("lib", posX: "1.00", overrides: Override("a", tint: "0.50") + Override("b", enable: false)))), + ("top.xml", Xml(references: Reference("mid", posY: "2.00", rotY: "90.00", overrides: Override("a", tint: "0.25"))))); + + var result = Resolve(pack, "top"); + + var a = result.Emitters.Single(); + Assert.Multiple(() => + { + Assert.That(result.Problems, Is.Empty); + Assert.That(a.Emitter.Id, Is.EqualTo("a"), "b is switched off by mid"); + Assert.That(a.Override.TintMin.X, Is.EqualTo(0.25f), "top's override, not mid's"); + // lib sits 1 m along X inside mid; mid is turned 90 degrees about Y and raised 2 m inside top. + Assert.That(Vector3.Distance(Vector3.Transform(Vector3.Zero, a.ReferenceTransform), new Vector3(0, 2, -1)), Is.LessThan(1e-4f)); + }); + } + + // A file that cannot be read (a pack rebuilt on disk while the editor has it open) threw out of Resolve, + // through MetaDataBuilder.Create, and wiped every tag in SuperView. + [Test] + public void UnreadableFile_IsAProblem_NotAThrow() + { + var path = Path.Combine(Path.GetTempPath(), $"vfx_test_gone_{Guid.NewGuid()}.xml"); + File.WriteAllBytes(path, Xml(emitters: Emitter("a"))); + var pack = Pack([PackFile.CreateFromFileSystem("gone.xml", path)]); + File.Delete(path); + VfxResolveResult? result = null; + Assert.That(() => result = Resolve(pack, "gone"), Throws.Nothing); + Assert.That(result!.Problems, Has.One.Matches(x => x.Contains("gone.xml"))); + } + + // A particle event's effect resolves as a top-level one would, into the event. One missing says so, and one already on + // the way to it (an effect whose particles start itself) is not followed again. + [Test] + public void ParticleEvents_ResolveTheirEffects_AndAreNotFollowedRoundACycle() + { + var events = """"""; + var pack = Pack(("fx.xml", Xml(emitters: Emitter("e", events))), ("boom.xml", Xml(emitters: Emitter("b")))); + var result = Resolve(pack, "fx"); + var e = result.Emitters.Single(); + Assert.Multiple(() => + { + Assert.That(e.Events!.Select(x => (x.Event.Effect, x.Emitters.Select(c => c.Emitter.Id).Single())), Is.EqualTo(new[] { ("boom", "b") })); + Assert.That(result.Problems, Has.One.Matches(x => x.Contains("cycle") && x.Contains("fx"))); + }); + var missing = Resolve(Pack(("fx.xml", Xml(emitters: Emitter("e", events.Replace("boom", "gone"))))), "fx"); + Assert.That(missing.Problems, Has.One.Matches(x => x.Contains("gone.xml"))); + } + + [Test] + public void ReferenceCycle_IsSkippedAndReported_NotFollowedForever() + { + var pack = Pack( + ("loop_a.xml", Xml(emitters: Emitter("in_a"), references: Reference("loop_b"))), + ("loop_b.xml", Xml(references: Reference("loop_a")))); + var result = Resolve(pack, "loop_a"); + Assert.That(result.Emitters.Select(x => x.Emitter.Id), Is.EqualTo(new[] { "in_a" })); + Assert.That(result.Problems, Has.One.Matches(x => x.Contains("cycle"))); + } + + [Test] + public void MissingReference_SkipsThatBranch_AndKeepsTheRest() + { + var result = Resolve(Pack(("top.xml", Xml(emitters: Emitter("own"), references: Reference("nowhere")))), "top"); + Assert.That(result.Emitters.Select(x => x.Emitter.Id), Is.EqualTo(new[] { "own" })); + Assert.That(result.Problems, Has.One.Matches(x => x.Contains("vfx/nowhere.xml"))); + } + + [Test] + public void MissingEffect_IsNotFound() => Assert.That(Resolve(Pack(), "nowhere").Found, Is.False); + + [TestCase("Top")] + [TestCase("top.xml")] + [TestCase(" top ")] + public void TagName_FindsTheFile_WhateverItsCaseOrExtension(string name) + { + var result = Resolve(Pack(("top.xml", Xml(emitters: Emitter("own")))), name); + Assert.That(result.Found, Is.True); + } + + [Test] + public void EmitterSwitchedOffAtUltra_IsDropped() + { + var off = Emitter("off", """"""); + Assert.That(Resolve(Pack(("fx.xml", Xml(emitters: off))), "fx").Emitters, Is.Empty); + } + + // A reference plays only the emitters it names: CA's editor lists every emitter its library had when the effect was + // made, and one the library gained later is not played. wh3_dlc23_entity_drazhoath_axe_fire_trail reaches a library + // whose flamethrower_continuous2 it never lists, and played, a weapon's fire trail threw flame 40 m. 1,824 of + // vanilla's 11,555 references leave some emitter unnamed, in 1,311 effects. The one that names none points at a file + // with no emitters of its own, so a reference naming none still plays them all. + [Test] + public void EmitterTheReferenceDoesNotName_IsNotPlayed() + { + var lib = ("lib.xml", Xml(emitters: Emitter("named") + Emitter("added_later"))); + var named = Resolve(Pack(lib, ("top.xml", Xml(references: Reference("lib", overrides: Override("named"))))), "top"); + var none = Resolve(Pack(lib, ("top.xml", Xml(references: Reference("lib")))), "top"); + Assert.Multiple(() => + { + Assert.That(named.Emitters.Select(x => x.Emitter.Id), Is.EqualTo(new[] { "named" })); + Assert.That(none.Emitters.Select(x => x.Emitter.Id), Is.EqualTo(new[] { "named", "added_later" })); + }); + } + + // An override reaches an emitter anywhere under its reference; one naming an id nothing there has does nothing, + // so it is said. A tracer switched on _rifle_100ms_aftertrail, which wh_main_lib_trail_small_arm does not contain. + [Test] + public void OverrideOfAnEmitterNothingBelowHas_IsAProblem() + { + var result = Resolve(Pack( + ("lib.xml", Xml(emitters: Emitter("a"))), + ("mid.xml", Xml(references: Reference("lib"))), + ("top.xml", Xml(references: Reference("mid", overrides: Override("a") + Override("ghost", enable: false))))), "top"); + Assert.That((result.Emitters.Count, result.Problems), Is.EqualTo((1, new List { "top: mid has no emitter ghost, so its override does nothing" }))); + } + } +} From 47dcd5456602780409ce7d4037a1c879838ce8db Mon Sep 17 00:00:00 2001 From: Yohann Gayao <68230605+DerpyNewb@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:37:00 +0800 Subject: [PATCH 3/4] SuperView: draw EFFECT tags' particles An EFFECT tag now draws its particles, simulated from vfx/.xml on the tag's bone, with the tag's start and end time, scale, orientation and position. Persistent effects keep running across the animation's loops. - Show VFX checkbox; unticked, no effect file is read and only the locators draw. - VFX filter: a tag's id is the vfx_filter_id of the battle entities it plays on, so a unit and its Regiment of Renown that share one meta each get their own effects. - The scene rebuilds when a vfx file, texture, model or animation a particle uses changes in a loaded pack. - A rebuild keeps what an unchanged tag built, so dragging a number in the meta panel no longer rebuilds every effect. --- AssetEditor/Language_Cn.json | 3 + AssetEditor/Language_En.json | 3 + AssetEditor/Language_Fr.json | 3 + .../SuperViewVfxRebuildTests.cs | 81 +++++ .../Test.AnimationMeta/VfxEffectTests.cs | 288 ++++++++++++++++++ .../AnimationMeta/SuperView/EditorView.xaml | 5 + .../SuperView/SuperViewViewModel.cs | 59 +++- .../Instances/VfxEffectInstance.cs | 76 +++++ .../Visualisation/MetaDataBuilder.cs | 251 ++++++++++++++- 9 files changed, 751 insertions(+), 18 deletions(-) create mode 100644 Editors/AnimationMeta/Test.AnimationMeta/SuperViewVfxRebuildTests.cs create mode 100644 Editors/AnimationMeta/Test.AnimationMeta/VfxEffectTests.cs create mode 100644 Editors/MetaDataEditor/AnimationMeta/SuperView/Visualisation/Instances/VfxEffectInstance.cs diff --git a/AssetEditor/Language_Cn.json b/AssetEditor/Language_Cn.json index 3a0f7a163..61dab7c30 100644 --- a/AssetEditor/Language_Cn.json +++ b/AssetEditor/Language_Cn.json @@ -409,6 +409,9 @@ "TextureEditor.Labels.NumMipMaps": "MipMap 数量", "MetadataEditor.Buttons.Refresh": "刷新", + "SuperView.ShowVfx": "显示特效", + "SuperView.VfxFilter": "特效过滤:", + "SuperView.VfxFilter.Tooltip": "单位在 battle_entities 中的 vfx_filter_id。特效标签的 id 若不是 0 也不是此值,就不会在该单位上播放:单位和它的传奇军团常共用一个元数据文件,分别用 id 1 和 id 2。", "MetadataEditor.Buttons.New": "新建", "MetadataEditor.Buttons.Delete": "删除", "MetadataEditor.Buttons.Up": "上移", diff --git a/AssetEditor/Language_En.json b/AssetEditor/Language_En.json index b73133b44..44d8e3020 100644 --- a/AssetEditor/Language_En.json +++ b/AssetEditor/Language_En.json @@ -409,6 +409,9 @@ "TextureEditor.Labels.NumMipMaps": "Num MipMaps", "MetadataEditor.Buttons.Refresh": "Refresh", + "SuperView.ShowVfx": "Show VFX", + "SuperView.VfxFilter": "VFX filter:", + "SuperView.VfxFilter.Tooltip": "The unit's vfx_filter_id in battle_entities. An effect tag whose id is neither 0 nor this does not play on it: a unit and its Regiment of Renown often share one meta, with id 1 and id 2.", "MetadataEditor.Buttons.New": "New", "MetadataEditor.Buttons.Delete": "Delete", "MetadataEditor.Buttons.Up": "Up", diff --git a/AssetEditor/Language_Fr.json b/AssetEditor/Language_Fr.json index 4d6e8e09a..3aba64c15 100644 --- a/AssetEditor/Language_Fr.json +++ b/AssetEditor/Language_Fr.json @@ -385,6 +385,9 @@ "TextureEditor.Labels.Height": "Height", "TextureEditor.Labels.NumMipMaps": "Num MipMaps", "MetadataEditor.Buttons.Refresh": "Refresh", + "SuperView.ShowVfx": "Afficher les VFX", + "SuperView.VfxFilter": "Filtre VFX :", + "SuperView.VfxFilter.Tooltip": "Le vfx_filter_id de l'unité dans battle_entities. Une balise d'effet dont l'id n'est ni 0 ni celui-ci n'est pas jouée sur elle : une unité et son régiment de renom partagent souvent un même méta, avec les id 1 et 2.", "MetadataEditor.Buttons.New": "New", "MetadataEditor.Buttons.Delete": "Delete", "MetadataEditor.Buttons.Up": "Up", diff --git a/Editors/AnimationMeta/Test.AnimationMeta/SuperViewVfxRebuildTests.cs b/Editors/AnimationMeta/Test.AnimationMeta/SuperViewVfxRebuildTests.cs new file mode 100644 index 000000000..fc1b6a6ff --- /dev/null +++ b/Editors/AnimationMeta/Test.AnimationMeta/SuperViewVfxRebuildTests.cs @@ -0,0 +1,81 @@ +using System.IO; +using Editors.AnimationMeta.SuperView; +using Editors.AnimationMeta.SuperView.Visualisation; +using Editors.Shared.Core.Common.BaseControl; +using Microsoft.Extensions.DependencyInjection; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Utility; +using Shared.Core.ToolCreation; +using Shared.GameFormats.AnimationPack; +using Test.TestingUtility.Shared; +using Test.TestingUtility.TestUtility; + +namespace Test.AnimationMeta +{ + public class SuperViewVfxRebuildTests + { + AssetEditorTestRunner _runner = null!; + SuperViewViewModel _superView = null!; + + [SetUp] + public void Setup() + { + _runner = new AssetEditorTestRunner(); + _runner.CreateCaContainer(); + _runner.LoadPackFile(PathHelper.GetDataFile("Throt.pack"), true); + + _superView = (SuperViewViewModel)_runner.ServiceProvider.GetRequiredService().Create(EditorEnums.SuperView_Editor)!; + _superView.Load(new AnimationToolInput + { + Mesh = _runner.PackFileService.FindFile(@"variantmeshes\variantmeshdefinitions\skv_throt.variantmeshdefinition")!, + FragmentName = @"animations/database/battle/bin/hu17_dlc16_throt.bin", + AnimationSlot = DefaultAnimationSlotTypeHelper.GetfromValue("ATTACK_5"), + }); + } + + // A vfx file added or saved in any pack (the VFX editor's save, a replaced texture) rebuilds the scene's effects, so + // SuperView shows it without the tab being reopened, and so does a model, material or animation a rigid particle may + // draw, wherever it lives; any other file leaves the scene alone. + [TestCase(@"vfx\test_smoke.xml", true)] + [TestCase(@"variantmeshes\wh_variantmodels\x\x.rigid_model_v2", true)] + [TestCase(@"variantmeshes\wh_variantmodels\x\x.wsmodel", true)] + [TestCase(@"variantmeshes\wh_variantmodels\x\x.xml.material", true)] + [TestCase(@"animations\battle\x\x.anim", true)] + [TestCase(@"text\notes.txt", false)] + public void AChangedVfxFile_RebuildsTheScene(string path, bool rebuilds) + { + var data = _superView.SceneObjects[0].Data; + var pack = _runner.CreateOutputPack(); + + var before = data.MetaDataItems; + _runner.PackFileService.AddFilesToPack(pack, [new NewPackFileEntry(Path.GetDirectoryName(path)!, PackFile.CreateFromBytes(Path.GetFileName(path), [1]))]); + Assert.That(data.MetaDataItems, rebuilds ? Is.Not.SameAs(before) : Is.SameAs(before), "on add"); + + before = data.MetaDataItems; + _runner.PackFileService.SaveFile(_runner.PackFileService.FindFile(path, pack)!, [2]); + Assert.That(data.MetaDataItems, rebuilds ? Is.Not.SameAs(before) : Is.SameAs(before), "on save"); + } + + // ...and builds every tag again from what the packs now hold, rather than keeping what it built from the old files. + [Test] + public void AChangedVfxFile_BuildsEveryTagAgain() + { + var data = _superView.SceneObjects[0].Data; + var before = data.MetaDataItems.ToList(); + Assert.That(before, Is.Not.Empty); + _runner.PackFileService.AddFilesToPack(_runner.CreateOutputPack(), [new NewPackFileEntry("vfx", PackFile.CreateFromBytes("test_smoke.xml", [1]))]); + Assert.That(data.MetaDataItems.Intersect(before), Is.Empty); + } + + [Test] + public void TheShowVfxBox_ReachesTheBuilder() + { + var builder = _runner.GetRequiredServiceInCurrentEditorScope(); + _superView.ShowVfx = false; + Assert.That(builder.ShowVfx, Is.False); + _superView.ShowVfx = true; + Assert.That(builder.ShowVfx, Is.True); + } + } +} diff --git a/Editors/AnimationMeta/Test.AnimationMeta/VfxEffectTests.cs b/Editors/AnimationMeta/Test.AnimationMeta/VfxEffectTests.cs new file mode 100644 index 000000000..ebab4c992 --- /dev/null +++ b/Editors/AnimationMeta/Test.AnimationMeta/VfxEffectTests.cs @@ -0,0 +1,288 @@ +using System.Text; +using Editors.AnimationMeta.SuperView.Visualisation; +using Editors.AnimationMeta.SuperView.Visualisation.Instances; +using Editors.Shared.Core.Common; +using GameWorld.Core.Animation; +using GameWorld.Core.SceneNodes; +using GameWorld.Core.Vfx; +using Microsoft.Xna.Framework; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.Animation; +using Shared.GameFormats.AnimationMeta.Definitions; +using Shared.GameFormats.AnimationMeta.Parsing; +using Shared.GameFormats.RigidModel.Transforms; +using Test.TestingUtility.Shared; + +namespace Test.AnimationMeta +{ + // EFFECT tags in SuperView: particles on the tag's bone, on the main animation's clock from the tag's Start Time. + public class VfxEffectTests + { + record Provider(GameSkeleton Skeleton) : ISkeletonProvider; + + // One emitter, 30 particles a second that live 10 s and do not move, carried by the bone. + const string Smoke = """x_min,0,0;0.0,30.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,30.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1x_min,0,0;0.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1"""; + + GroupNode _root = null!; + AnimationPlayer _player = null!; + Provider _skeleton = null!; + MetaDataBuilder _builder = null!; + AssetEditorTestRunner _runner = null!; + + [TearDown] + public void TearDown() => _builder.Dispose(); + + [SetUp] + public void Setup() + { + // PackFileService is internal to Shared.Core; the runner's is the public way to an in-memory pack. + var runner = _runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + var pack = runner.CreateOutputPack(); + runner.PackFileService.AddFilesToPack(pack, [new NewPackFileEntry("vfx", PackFile.CreateFromBytes("test_smoke.xml", Utf16(Smoke)))]); + // Only the EFFECT path runs here, and it uses none of the mesh, render or skeleton loaders. + _builder = new MetaDataBuilder(null!, null!, null!, runner.PackFileService, null!, null!); + + // Two bones; the fx bone walks 2 m/s along X, 1 m up, in 21 frames over 2 s so whole-frame times sample exactly. + _player = new AnimationPlayer(); + var skeleton = new GameSkeleton(SkeletonFile(), _player); + var clip = new AnimationClip { PlayTimeInSec = 2 }; + for (var frame = 0; frame <= 20; frame++) + { + clip.DynamicFrames.Add(new AnimationClip.KeyFrame + { + Position = [Vector3.Zero, new Vector3(frame * 0.2f, 1, 0)], + Rotation = [Quaternion.Identity, Quaternion.Identity], + Scale = [Vector3.One, Vector3.One], + }); + } + _player.SetAnimation(clip, skeleton); + _player.IsEnabled = true; + _skeleton = new Provider(skeleton); + _root = new GroupNode("root"); + } + + static AnimationFile SkeletonFile() + { + var file = new AnimationFile(); + file.Header.SkeletonName = "test_skeleton"; + file.Bones = + [ + new AnimationFile.BoneInfo { Id = 0, Name = "root", ParentId = AnimationFile.BoneIndexNoParent }, + new AnimationFile.BoneInfo { Id = 1, Name = "fx", ParentId = 0 }, + ]; + var bind = new AnimationFile.Frame(); + bind.Transforms.Add(new RmvVector3(0, 0, 0)); + bind.Transforms.Add(new RmvVector3(0, 1, 0)); + bind.Quaternion.Add(new RmvVector4(0, 0, 0, 1)); + bind.Quaternion.Add(new RmvVector4(0, 0, 0, 1)); + var part = new AnimationFile.AnimationPart(); + part.DynamicFrames.Add(bind); + file.AnimationParts.Add(part); + return file; + } + + static byte[] Utf16(string text) => [.. Encoding.Unicode.GetPreamble(), .. Encoding.Unicode.GetBytes(text)]; + + static Effect_v11 Tag(string vfx, int bone = 1, float start = 0.5f, bool tracking = true) => + new() { VfxName = vfx, NodeIndex = bone, StartTime = start, EndTime = 0, Tracking = tracking }; + + static ParsedMetadataFile Meta(params ParsedMetadataAttribute[] tags) => new() { Version = 2, Attributes = [.. tags] }; + + List Build(ParsedMetadataFile? persistent, ParsedMetadataFile meta) => + _builder.Create(persistent, meta, null, _root, _skeleton, _player, null!); + + static List States(VfxEffectInstance instance) => instance.Simulator!.Emitters.SelectMany(ParticleSimulator.States).ToList(); + + [Test] + public void Effect_Spawns_OnItsBone_OnlyAfterItsStartTime() + { + var instance = (VfxEffectInstance)Build(null, Meta(Tag("test_smoke"))).Single(); + + instance.Update(0.4f); + Assert.That(States(instance), Is.Empty, "particles before the tag's Start Time"); + + instance.Update(1.5f); + var states = States(instance); + Assert.That(states.Count, Is.EqualTo(30).Within(1), "one second of 30 a second"); + // At 1.5 s the bone is at (3, 1, 0); local-space particles ride on it. + Assert.That(states.Max(x => Vector3.Distance(x.Position, new Vector3(3, 1, 0))), Is.LessThan(0.01f)); + } + + // The 2 s clip plays 1.5, 1.9, loops to 0.1, then 0.5: a persistent effect runs on across the loop (2.5 s of + // particles), a tag in the animation's own meta restarts with it (0.5 s), and both ride the bone where the clip is now. + [TestCase(true, 75)] + [TestCase(false, 15)] + public void PersistentEffect_RunsOnAcrossALoop(bool persistent, int particles) + { + var tag = Meta(Tag("test_smoke", start: 0)); + var instance = (VfxEffectInstance)(persistent ? Build(tag, Meta()) : Build(null, tag)).Single(); + foreach (var time in new[] { 1.5f, 1.9f, 0.1f, 0.5f }) + instance.Update(time); + var states = States(instance); + Assert.That(states.Count, Is.EqualTo(particles).Within(1)); + Assert.That(states.Max(x => Vector3.Distance(x.Position, new Vector3(1, 1, 0))), Is.LessThan(0.01f)); + } + + [Test] + public void UntrackedEffect_StaysWhereItsBoneWasAtTheStart() + { + var instance = (VfxEffectInstance)Build(null, Meta(Tag("test_smoke", tracking: false))).Single(); + instance.Update(1.5f); + // The bone was at (1, 1, 0) at the tag's 0.5 s start. + Assert.That(States(instance).Max(x => Vector3.Distance(x.Position, new Vector3(1, 1, 0))), Is.LessThan(0.01f)); + } + + [Test] + public void MissingEffect_KeepsItsLocator_AndDrawsNoParticles() + { + Assert.DoesNotThrow(() => Build(null, Meta(Tag("no_such_fx")))); + Assert.That(_root.Children.Select(x => x.Name), Is.EquivalentTo(new[] { "Effect:no_such_fx" })); + } + + [Test] + public void ShowVfxOff_DrawsTheLocatorOnly() + { + _builder.ShowVfx = false; + var instance = (VfxEffectInstance)Build(null, Meta(Tag("test_smoke"))).Single(); + Assert.That(instance.Simulator, Is.Null); + Assert.That(_root.Children.OfType(), Is.Empty); + } + + // A tag's id is the vfx_filter_id of the battle entities it plays on (battle_entities_tables): 0 plays on every entity, + // any other id only on the entity with that filter. The Terracotta Sentinel's metas carry every effect twice, id 1 for + // the unit and id 2 for its Regiment of Renown, and SuperView drew both. Unset, the filter is the lowest id the two + // metas carry, the persistent one's included. + [TestCase(null, new[] { "Effect:every_entity", "Effect:filter_1" })] + [TestCase(2, new[] { "Effect:every_entity", "Effect:filter_2" })] + public void TaggedEffect_PlaysOnlyOnItsVfxFilter(int? filter, string[] drawn) + { + static Effect_v11 Filtered(string vfx, int id) + { + var tag = Tag(vfx); + tag.Id = id; + return tag; + } + + _builder.VfxFilterId = filter; + Build(Meta(Filtered("filter_1", 1)), Meta(Filtered("every_entity", 0), Filtered("filter_2", 2))); + Assert.That(_root.Children.Select(x => x.Name), Is.EquivalentTo(drawn)); + } + + [Test] + public void DisablePersistentVfx_DropsThePersistentMetasEffects() + { + var persistent = Meta(Tag("test_smoke", start: 0)); + Build(persistent, Meta(new DisablePersistantVfx_v10())); + Assert.That(_root.Children.OfType(), Is.Empty, "DISABLE_PERSISTENT_VFX kept the persistent meta's effect"); + Assert.That(Build(persistent, Meta()), Has.Count.EqualTo(1), "without it, the persistent effect is built"); + } + + // Every edit in the meta panel rebuilds every effect, so a rebuild reuses what it resolved of an effect's vfx file and + // reads the file again only once Forget names a changed one, as SuperView does when a pack changes: smoke rewritten to + // emit nothing shows only after. + [Test] + public void Rebuild_ReusesTheResolvedFile_UntilForgetNamesIt() + { + var tag = Meta(Tag("test_smoke")); + int Particles() + { + var instance = (VfxEffectInstance)Build(null, tag).Single(); + instance.Update(1.5f); + return States(instance).Count; + } + Particles(); + _runner.PackFileService.SaveFile(_runner.PackFileService.FindFile(@"vfx\test_smoke.xml")!, Utf16(Smoke.Replace("30.0", "0.0"))); + var reused = Particles(); + _builder.Forget([@"vfx\test_smoke.xml"]); + var reread = Particles(); + Assert.Multiple(() => + { + Assert.That(reused, Is.EqualTo(30).Within(1), "the rebuild read the file again"); + Assert.That(reread, Is.Zero, "Forget kept the old file"); + }); + } + + // ...and samples the bone afresh when the animation changes: rebuilt on a clip that holds the fx bone at (0, 1, 5), the + // smoke rides there, not where the first clip had the bone at the same times; and an untracked effect, which keeps where + // its bone was at the start, takes that from the new clip too. + [TestCase(true)] + [TestCase(false)] + public void Rebuild_AfterTheClipChanges_RidesTheNewClip(bool tracking) + { + var tag = Meta(Tag("test_smoke", tracking: tracking)); + ((VfxEffectInstance)Build(null, tag).Single()).Update(1.5f); + var held = new AnimationClip { PlayTimeInSec = 2 }; + for (var frame = 0; frame <= 20; frame++) + { + held.DynamicFrames.Add(new AnimationClip.KeyFrame + { + Position = [Vector3.Zero, new Vector3(0, 1, 5)], + Rotation = [Quaternion.Identity, Quaternion.Identity], + Scale = [Vector3.One, Vector3.One], + }); + } + _player.SetAnimation(held, _skeleton.Skeleton); + var instance = (VfxEffectInstance)Build(null, tag).Single(); + instance.Update(1.5f); + Assert.That(States(instance).Max(x => Vector3.Distance(x.Position, new Vector3(0, 1, 5))), Is.LessThan(0.01f)); + } + + // A slider in the meta panel rebuilds on every step: the effect whose tag did not change keeps what was built for it, + // particles and all, and only the one that changed is built again. + [Test] + public void Rebuild_KeepsTheEffectsWhoseTagsDidNotChange() + { + var still = Tag("test_smoke"); + var moved = Tag("test_smoke", start: 0.2f); + var meta = Meta(still, moved); + var first = Build(null, meta); + first[0].Update(1.5f); + moved.Position = new Vector3(0, 0, 1); + var second = Build(null, meta); + Assert.Multiple(() => + { + Assert.That(second[0], Is.SameAs(first[0]), "the unchanged effect was built again"); + Assert.That(States((VfxEffectInstance)second[0]), Has.Count.EqualTo(30).Within(1), "and lost its particles"); + Assert.That(second[1], Is.Not.SameAs(first[1]), "the moved effect was kept"); + }); + } + + // ...but anything that changes what an effect draws builds it again: its colour when it is picked in the list, the + // vfx toggle, another skeleton, or the caller having cleaned it up out of the scene (as a mesh change does). + [TestCase("picked")] + [TestCase("vfx off")] + [TestCase("cleaned up")] + [TestCase("skeleton swapped")] + public void Rebuild_BuildsAnEffectAgain_When(string change) + { + var tag = Tag("test_smoke"); + var meta = Meta(tag); + var first = Build(null, meta).Single(); + ParsedMetadataAttribute? picked = null; + if (change == "picked") + picked = tag; + if (change == "vfx off") + _builder.ShowVfx = false; + if (change == "cleaned up") + first.CleanUp(); + if (change == "skeleton swapped") + _skeleton = new Provider(new GameSkeleton(SkeletonFile(), _player)); + var second = _builder.Create(null, meta, picked, _root, _skeleton, _player, null!).Single(); + Assert.Multiple(() => + { + Assert.That(second, Is.Not.SameAs(first)); + Assert.That(_root.Children.Count(x => x.Name == "Effect:test_smoke"), Is.EqualTo(change == "cleaned up" ? 1 : 2)); + }); + } + + [Test] + public void EffectOnABoneTheSkeletonLacks_DrawsAtTheOrigin() + { + var instance = (VfxEffectInstance)Build(null, Meta(Tag("test_smoke", bone: 99))).Single(); + Assert.DoesNotThrow(() => instance.Update(1.5f)); + Assert.That(States(instance).Max(x => x.Position.Length()), Is.LessThan(0.01f)); + } + } +} diff --git a/Editors/MetaDataEditor/AnimationMeta/SuperView/EditorView.xaml b/Editors/MetaDataEditor/AnimationMeta/SuperView/EditorView.xaml index a5b84e149..43b37ed6e 100644 --- a/Editors/MetaDataEditor/AnimationMeta/SuperView/EditorView.xaml +++ b/Editors/MetaDataEditor/AnimationMeta/SuperView/EditorView.xaml @@ -10,6 +10,11 @@ d:DesignHeight="450" d:DesignWidth="800">