diff --git a/.gitignore b/.gitignore index 0070e3cd..c19f1186 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ sourcemap.json /node_modules /package-lock.json + # FastCast2 related stuff /src/*.legacy.luau .aider* @@ -24,4 +25,4 @@ sourcemap.json # Development related stuff DevPackages -build \ No newline at end of file +build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1fe96dc..f5acfa64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,6 @@ This project is open-source and community-driven, and **all kinds of help are we - Commit your changes - Push to your fork - Open a **Pull Request** against the `main` branch - - You must provide **RBXM** file to the author for release (Optional)
- > (Only if the author aka **Mawin_CK** himself requested you to provide **RBXM** for the release) All pull requests will go through **code review** before merging. @@ -33,7 +31,7 @@ All pull requests will go through **code review** before merging. ### Commit Message Guidelines Examples: -- `fix: prevent nil reference in ObjectCache` +- `fix: prevent nil reference in cast cleanup` - `feat: add BulkMoveTo support for parts.` - `docs: improve usage example for parallel mode.` - `chore: update Luau types in Caster.` @@ -58,7 +56,9 @@ I'm not strict about changelogs, and I don't really bother writing explicit chan - Keep discussions technical and constructive ### Testing -- Read manual testing instructions: https://github.com/weenachuangkud/FastCast2/blob/main/TESTS.md +- Run the automated suite before opening a PR: `npm test` (or `lune run tests/run.luau`) +- It runs the functional tests and benchmark matrix in Studio and uploads the report to paste.shellworks.dev; see [`tests/README.md`](tests/README.md) +- Manual checklist / coverage reference: [`TESTS.md`](TESTS.md) ### Questions? diff --git a/README.md b/README.md index 47dca0a5..209a0ffb 100644 --- a/README.md +++ b/README.md @@ -45,69 +45,70 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith - Raycast and Blockcast, Spherecast support - BulkMoveTo/Motor6D support - Built-in castVisualization -- Built-in ObjectCache - Built-in HighFidelitySegment control -- Flexible and extensible design +- Flexible, extensible - High Performance -FastCast2 is an open-source project, and contributions from the community are welcome.
-Read more on [FastCast2 devforum](https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed/4093890) +FastCast2 is an open-source project, and contributions from the community are welcome — see [Contributing](#contributing) below. --- -# Installation guide +## Installation guide -1. Go to [Releases](https://github.com/weenachuangkud/FastCast2/releases) and install the `.rbxm` file from the latest release. +### 1. Model file (.rbxm) + +1. Go to [Releases](https://github.com/weenachuangkud/FastCast2/releases) and download the `.rbxm` file from the latest release. 2. Open Roblox Studio and open any project. 3. Go to **File → Import Roblox Model** and import the `.rbxm` file. 4. After importing, **FastCast2** will appear in your Workspace. 5. Drag **FastCast2** into **ReplicatedStorage**. -6. Done, and you’re ready to use FastCast2. - ---- - -## Install with [Wally](https://wally.run/) - -1. Make you have installed Wally: https://wally.run/install -2. After you've installed Wally, make sure to `wally init` -3. Then inside `wally.toml` below `[dependencies]` copy and paste `FastCast2 = "weenachuangkud/fastcast2@0.1.4"` -4. Run `wally install`, and you're ready to use - -NOTE: This does not include rojo setup +6. Done — you're ready to use FastCast2. + +### 2. Install with [Wally](https://wally.run/) + +1. Make sure you have [Wally installed](https://wally.run/install). +2. Run `wally init` if you haven't already. +3. Add FastCast2 under `[dependencies]` in your `wally.toml`: +```toml + FastCast2 = "weenachuangkud/fastcast2@0.1.4" +``` + +4. Run `wally install` and you're ready to use it. +> **Note:** This does not include a Rojo setup, you'll need your own `default.project.json` to sync `Packages` into your place. --- -# Code example - -Shooting projectiles from your head (Serial mode - simpler, main thread): - +## Quick start + +Shooting projectiles from your head (Serial mode, simpler, runs on the main thread): + ```lua -- Services local Rep = game:GetService("ReplicatedStorage") local Players = game:GetService("Players") - + -- Requires local FastCast2 = require(Rep:WaitForChild("FastCast2")) local FastCastEnums = require(Rep:WaitForChild("FastCast2"):WaitForChild("FastCastEnums")) - + -- CONSTANTS local SPEED = 500 - + -- Variables local player = Players.LocalPlayer local character = player.Character or player.CharacterAdded:Wait() local Head = character:WaitForChild("Head") local Mouse = player:GetMouse() - + local ProjectileContainer = workspace:WaitForChild("Projectiles") local ProjectileTemplate = Rep:WaitForChild("Projectile") - + -- CastParams local CastParams = RaycastParams.new() CastParams.FilterDescendantsInstances = {character} CastParams.FilterType = Enum.RaycastFilterType.Exclude CastParams.IgnoreWater = true - + -- Behavior (FastCastBehavior) local behavior = FastCast2.newBehavior() behavior.MaxDistance = 1000 @@ -117,24 +118,23 @@ behavior.HighFidelitySegmentSize = 1 behavior.Acceleration = Vector3.new(0, -workspace.Gravity/2.3, 0) behavior.CosmeticBulletContainer = ProjectileContainer behavior.CosmeticBulletTemplate = ProjectileTemplate - - + -- Serial Caster (runs on main thread, simpler) local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", false) -- movementMode, useObjectCache - +Caster:Init("BulkMoveTo") -- movementMode + -- Events (can be set before Init) Caster.Hit = function(cast, result, velocity, bullet) print("Hit: " .. result.Instance.Name) end - + -- Fire local function fire() local origin = Head.Position local direction = (Mouse.Hit.Position - origin).Unit Caster:RaycastFire(origin, direction, SPEED, behavior) end - + -- Input game:GetService("UserInputService").InputBegan:Connect(function(input, gameProcessed) if gameProcessed then return end @@ -145,63 +145,52 @@ end) ``` ### Blockcast & Spherecast - -Swap `RaycastFire` for `BlockcastFire` or `SpherecastFire` to change cast type: - + +Swap `RaycastFire` for `BlockcastFire` or `SpherecastFire` to change the cast type: + ```lua -- Blockcast: pass a Vector3 size after origin Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) - + -- Spherecast: pass a number radius after origin Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` -### ObjectCache (Or object pooling) +### Cosmetic bullets -ObjectCache reuses cosmetic bullet instances instead of creating/destroying them every shot: +FastCast2 no longer includes a built-in ObjectCache. Manage cosmetic bullet instances yourself and provide them through `behavior.CosmeticBulletTemplate` and `behavior.CosmeticBulletContainer`. -```lua -local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) --- ^template ^size ^holder -``` - -The cache pre-allocates 500 parts by default, auto-expands when exhausted, and moves retired -parts to a far-away CFrame via `BulkMoveTo` — no instance creation/destruction overhead. +FastCast2 also does not automatically destroy or return cosmetic bullets when a cast terminates, so the caller controls their lifecycle. ### Parallel mode + ```lua local Caster = FastCast2.newParallel() Caster:Init( 4, -- numWorkers - workspace, -- VM folder parent - "FastCastVMs", -- VM folder name - workspace, -- container parent - "VMContainer", -- container name - "VM", -- VM name "BulkMoveTo", -- movementMode nil, -- FastCastEventsModule (optional) - false -- useObjectCache ) - + -- Events work the same as serial Caster.Hit = function(cast, result, velocity, bullet) print("Hit: " .. result.Instance.Name) end - + --[[ -- Except: -- Caster.CanPierce --> You will have to use FastCastEventsModule for that ]] - + -- Fire the same as serial Caster:RaycastFire(origin, direction, SPEED, behavior) Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` +
How to set up [FastCastEventsModule](https://weenachuangkud.github.io/FastCast2/api/TypeDefinitions/#FastCastEventsModule) @@ -277,7 +266,7 @@ Motor6D mode uses `Motor6D.Transform` for more performance instead of `BulkMoveT ```lua local Caster = FastCast2.new() -Caster:Init("Motor6D", false) -- movementMode = "Motor6D" +Caster:Init("Motor6D") -- movementMode = "Motor6D" ``` All active casts automatically get a Motor6D connection on registration and disconnection on cleanup. @@ -288,6 +277,18 @@ Caster:SetMovementModeEnabled(true, "Motor6D") -- enable Motor6D Caster:SetMovementModeEnabled(true, "BulkMoveTo") -- switch back ``` +### Simulation configuration + +Simulation behavior can be configured in `src/Config.luau`: + +```lua +Config.SimulationMode = "PerFrame" -- or "Fixed" +Config.FixedSimulationFrequency = Enum.StepFrequency.Hz60 +Config.CosmeticUpdateInterval = 3 +``` + +`PerFrame` uses `PreSimulation` on the client and `Heartbeat` on the server. `Fixed` uses `RunService:BindToSimulation()` at the configured step frequency. `CosmeticUpdateInterval` controls how often cosmetic bullet movement is applied; hit detection still runs every simulation step. + ### Cast manipulation Modify active casts at runtime using the static `FastCast` methods: @@ -316,11 +317,33 @@ FastCast2:TerminateCast(cast) ### -> Get started with the [FastCast2 documentation](https://weenachuangkud.github.io/FastCast2/docs/api-reference) +--- + +## Contributing + +FastCast2 is community-driven, and contributions of all kinds are welcome, code, documentation, bug reports, examples, or ideas. + +1. Found a bug or have an idea? Open an [Issue](https://github.com/weenachuangkud/FastCast2/issues) first. +2. Want to submit code or docs? Fork the repo, branch off `main`, make your changes, and open a Pull Request. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full guide and [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) for community expectations. + +
+ + +## Benchmarks + +FastCast2 includes repeatable client and server benchmarks for raycasts, blockcasts, and spherecasts. They compare serial and parallel execution using creation/cleanup throughput and simulation frame-time percentiles. See the [benchmark guide](benchmarks/README.md) for controls and the ready-to-sync Rojo project. + +## Tests + +The automated suite runs the functional tests and the six-case benchmark matrix inside Roblox Studio, prints a Markdown results table, and uploads the report to [paste.shellworks.dev](https://paste.shellworks.dev). Run it with `npm test` (or `lune run tests/run.luau`). See the [test guide](tests/README.md) for prerequisites and flags. + + --- # People behind FastCast2(Contributors) - [CK06](https://github.com/weenachuangkud): Main developer, Maintainer, Graphic designer -- [Naymmmm](https://github.com/Naymmmm): Maintainer (Inactive) +- [Naymmmm](https://github.com/Naymmmm): Maintainer - [EtiTheSpirit](https://github.com/EtiTheSpirit): Original developer - [Per2iako](https://github.com/Per2iako): Maintainer @@ -342,5 +365,4 @@ More broadly, this community feedback has played a significant role in FastCast2 - [90gq31](https://computerunion.miraheze.org/90gq31) — Giving me the opportunity to be part of the community # Dependency -- [ObjectCache](https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112) - [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher) diff --git a/README.th.md b/README.th.md index f00234a3..72809c03 100644 --- a/README.th.md +++ b/README.th.md @@ -45,7 +45,6 @@ - รองรับ Raycast, Blockcast และ Spherecast - รองรับ BulkMoveTo/Motor6D - มี castVisualization ในตัว -- มี ObjectCache ในตัว - มีระบบควบคุม HighFidelitySegment ในตัว - ออกแบบมาให้ยืดหยุ่นและต่อยอดได้ง่าย - ช่วยเพิ่มประสิทธิภาพในการพัฒนา @@ -127,7 +126,7 @@ behavior.CosmeticBulletTemplate = ProjectileTemplate -- Serial Caster (รันบน main thread ใช้งานง่ายกว่า) local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", false) -- movementMode, useObjectCache +Caster:Init("BulkMoveTo") -- movementMode -- Events (ตั้งค่าได้ก่อน Init) Caster.Hit = function(cast, result, velocity, bullet) @@ -162,20 +161,14 @@ Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` -### ObjectCache (bullet pooling) +### Cosmetic bullet -ObjectCache ช่วยนำ instance ของ cosmetic bullet กลับมาใช้ซ้ำ แทนที่จะสร้าง/ทำลายทุกครั้งที่ยิง: +FastCast2 ไม่มี ObjectCache ในตัวแล้ว คุณสามารถจัดการ cosmetic bullet ด้วยระบบของคุณเอง และกำหนดผ่าน `behavior.CosmeticBulletTemplate` กับ `behavior.CosmeticBulletContainer` ได้ -```lua -local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) --- ^template ^size ^holder -``` +FastCast2 จะไม่ลบหรือคืน cosmetic bullet ให้อัตโนมัติเมื่อ cast จบ ทำให้คุณควบคุม lifecycle ของ projectile visual ได้เอง -โดยค่าเริ่มต้น cache จะเตรียม part ไว้ล่วงหน้า 500 ชิ้น และขยายเพิ่มอัตโนมัติเมื่อใช้จนหมด -พร้อมทั้งย้าย part ที่เลิกใช้ไปไว้ที่ CFrame ระยะไกลผ่าน `BulkMoveTo` — ไม่มีภาระจากการสร้าง/ทำลาย instance - -### โหมด Parallel (ประสิทธิภาพสูงด้วยหลาย VM) +### โหมด Parallel + (ประสิทธิภาพสูงด้วยหลาย VM) ```lua local Caster = FastCast2.newParallel() @@ -188,7 +181,6 @@ Caster:Init( "VM", -- VM name "BulkMoveTo", -- movementMode nil, -- FastCastEventsModule (ไม่บังคับ) - false -- useObjectCache ) -- Events ทำงานเหมือนกับโหมด Serial @@ -283,7 +275,7 @@ Caster:SetFastCastEventsModule(pathTo.FastCastEventsModule) ```lua local Caster = FastCast2.new() -Caster:Init("Motor6D", false) -- movementMode = "Motor6D" +Caster:Init("Motor6D") -- movementMode = "Motor6D" ``` ทุก cast ที่ active จะได้รับการเชื่อมต่อ Motor6D โดยอัตโนมัติเมื่อลงทะเบียน และตัดการเชื่อมต่อเมื่อ cleanup @@ -294,6 +286,18 @@ Caster:SetMovementModeEnabled(true, "Motor6D") -- เปิดใช้งา Caster:SetMovementModeEnabled(true, "BulkMoveTo") -- สลับกลับไปใช้ BulkMoveTo ``` +### การตั้งค่าการจำลอง + +สามารถตั้งค่ารูปแบบการจำลองได้ที่ `src/Config.luau`: + +```lua +Config.SimulationMode = "PerFrame" -- หรือ "Fixed" +Config.FixedSimulationFrequency = Enum.StepFrequency.Hz60 +Config.CosmeticUpdateInterval = 3 +``` + +`PerFrame` ใช้ `PreSimulation` บน client และ `Heartbeat` บน server ส่วน `Fixed` ใช้ `RunService:BindToSimulation()` ตามความถี่ที่กำหนด `CosmeticUpdateInterval` กำหนดว่าจะอัปเดตการเคลื่อนที่ของ cosmetic bullet ทุกกี่ simulation frames โดยการตรวจจับการชนยังทำงานทุก simulation step + ### การจัดการ Cast แก้ไข cast ที่กำลัง active อยู่ระหว่าง runtime โดยใช้ static method ของ `FastCast`: @@ -346,5 +350,4 @@ FastCast2:TerminateCast(cast) - [@nerd0ne](https://github.com/nerd0ne) — ที่ชี้ให้เห็นปัญหา Wally setup # Dependency -- [ObjectCache](https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112) -- [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher) +- [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher)(https://github.com/weenachuangkud/VMsDispatcher) diff --git a/TESTS.md b/TESTS.md index dd8562b6..33d8d480 100644 --- a/TESTS.md +++ b/TESTS.md @@ -1,13 +1,32 @@ +# Testing FastCast2 + +The checklist below is covered by the automated suite. Run it with one command +from the repository root: + +```sh +npm test +# or +lune run tests/run.luau +``` + +The runner builds the test place, runs it in Roblox Studio via +`run-in-roblox`, prints a functional-test summary and a benchmark table, and +uploads the full report to [paste.shellworks.dev](https://paste.shellworks.dev). +See [`tests/README.md`](tests/README.md) for prerequisites, flags, and the +project layout. + +The same cases can still be verified by hand if Studio automation is not +available. + ### Caster 1. Initialization 2. Basic raycast/blockcast/spherecast firing 3. All [events](https://weenachuangkud.github.io/FastCast2/docs/api-reference/#112-events) should be able to works -4. ObjectCache -5. MovementMode -6. Cast Manipulation -7. Repeat the same tests above for parallel version -8. SetFastCastEventsModule (Parallel) -9. SyncChangesToCast (Parallel) +4. MovementMode +5. Cast Manipulation +6. Repeat the same tests above for parallel version +7. SetFastCastEventsModule (Parallel) +8. SyncChangesToCast (Parallel) ### FastCastBehavior - https://weenachuangkud.github.io/FastCast2/docs/api-reference/#2-fastcastbehavior ### ActiveCastData diff --git a/TODO.md b/TODO.md index 84b97e46..3b11fca2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,17 +1,18 @@ - [ ] Create multiple games, libraries, and frameworks using FastCast2, and showcase them in `docs/showcase.md` (DOCS) -- [ ] Create a Debugger GUI for benchmarking and testing (debuggergui) -- [ ] Add a manual test checklist MD -- [ ] Code improvements -- [ ] Add `BindToSimulation` and `UseFixedSimulation` (changes) +- [ ] ~~Create a Debugger GUI for benchmarking and testing (debuggergui)~~ (Cancalled) +- [X] Add a manual test checklist MD +- [X] Code improvements +- [ ] ~~Add dynamic RunService event configuration for Caster (feats)~~ (Cancelled) +- [X] Add `BindToSimulation` (changes) - [ ] Migration guide from FastCast Redux/FastCast (DOCS) - [ ] Performance guide and best practices (DOCS) - [ ] Common pitfalls and FAQ (DOCS) -- [ ] Add benchmark pics to `docs/` and `README.md` (DOCS) +- [X] ~~Add benchmark pics to `docs/` and `README.md` (DOCS)~~ (Naymmmm works) - [ ] Add benchmark GIF demo to `README.md` showing projectiles in action (DOCS) -- [ ] troubleshooting guide (DOCS) +- [ ] ~~troubleshooting guide (DOCS)~~ (Cancelled) - [ ] advanced examples (DOCS) - [ ] Create a new FastCast2 cover image (DOCS) - [ ] Create a FastCast2 tutorial video (DOCS) - [ ] Include the `.rbxl` file in the latest release (RELEASE) - [ ] Update Roblox Model of FastCast2 (RELEASE) -- [ ] Update README.md (DOCS) +- [X] Update README.md (DOCS) diff --git a/benchmarks.project.json b/benchmarks.project.json new file mode 100644 index 00000000..c4e0af73 --- /dev/null +++ b/benchmarks.project.json @@ -0,0 +1,27 @@ +{ + "name": "FastCast2Benchmarks", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "FastCast2": { + "$path": "src" + }, + "Packages": { + "$path": "benchmarks/Packages" + }, + "BenchmarkPackets": { + "$path": "benchmarks/BenchmarkPackets.luau" + } + }, + "ReplicatedFirst": { + "BenchmarkClient": { + "$path": "benchmarks/BenchmarkClient.client.luau" + } + }, + "ServerScriptService": { + "BenchmarkServer": { + "$path": "benchmarks/BenchmarkServer.server.luau" + } + } + } +} diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index 4b74c578..f2cbf779 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -9,16 +9,17 @@ K -500 projectiles U Toggle Instanced / Instanceless J Toggle BulkMoveTo / Motor6D - Y Toggle ObjectCache V Toggle Cast Visualization X Cycle HighFidelityBehavior > Increase HighFidelitySegmentSize < Decrease HighFidelitySegmentSize B Toggle Serial / Parallel + R Cycle cast type [ Decrease workers ] Increase workers I Show benchmark information H Show controls + N Toggle Client / Server input mode ]] --// Services @@ -36,21 +37,18 @@ local Config = { ProjectileAmount = 500, Velocity = 6, + CastType = "Raycast", + RandomSeed = 2026, + BlockcastSize = Vector3.one, + SpherecastRadius = 0.5, Instanced = true, - ObjectCacheEnabled = false, VisualizeCasts = false, BenchmarkDuration = 6, MovementMode = "BulkMoveTo", - VMName = "CastVMs", - VMContainerName = "CastVMContainer", - VMTemplateName = "CastVM", - - FPSUpdateRate = 0.5, - HighFidelitySegmentSizeStep = 0.05, MinHighFidelitySegmentSize = 0.05, @@ -62,6 +60,7 @@ local Config = { local NUM_WORKERS_CHANGE_COOLDOWN = 4.5 local MODE_CHANGE_COOLDOWN = 2.5 local BENCHMARK_COOLDOWN = 2.5 +local INPUT_MODE_TOGGLE_KEY = Enum.KeyCode.N --// Modules @@ -72,6 +71,10 @@ local FastCastEnums = require( FastCastModule:WaitForChild("FastCastEnums") ) +local BenchmarkPackets = require( + ReplicatedStorage:WaitForChild("BenchmarkPackets") +) + --// Objects local ProjectileContainer = Instance.new("Folder") @@ -124,51 +127,17 @@ local ActiveCasts = {} local IsBenchmarking = false local IsReconfiguring = false ---// FPS Statistics - -local FPS = { - Current = 0, - Average = 0, - Minimum = math.huge, - Maximum = 0, - - Accumulator = 0, - FrameCount = 0, -} - -local function resetFPS() - FPS.Current = 0 - FPS.Average = 0 - FPS.Minimum = math.huge - FPS.Maximum = 0 - - FPS.Accumulator = 0 - FPS.FrameCount = 0 -end - -local function updateFPS(dt) - if dt <= 0 then - return - end - - local current = 1 / dt - - FPS.Current = current - FPS.Minimum = math.min(FPS.Minimum, current) - FPS.Maximum = math.max(FPS.Maximum, current) +local InputMode = "Client" - FPS.Accumulator += current - FPS.FrameCount += 1 +--// Frame Statistics - if FPS.FrameCount >= 1 then - -- Update the rolling average periodically rather than - -- calculating it every frame. - FPS.Average = FPS.Accumulator / FPS.FrameCount - end -end +local FrameTimes = {} +local IsMeasuringFrames = false RunService.Heartbeat:Connect(function(dt) - updateFPS(dt) + if IsMeasuringFrames and dt > 0 then + FrameTimes[#FrameTimes + 1] = dt * 1000 + end end) --// Utility @@ -177,22 +146,6 @@ local function getMode() return Config.Parallel and "PARALLEL" or "SERIAL" end -local function getObjectCacheState() - if not Caster then - return false - end - - if Caster.ObjectCacheEnabled then - return true - end - - if Caster.BaseCast and Caster.BaseCast.ObjectCacheInstance then - return true - end - - return false -end - local function getHighFidelityName() local behavior = CastBehavior.HighFidelityBehavior @@ -223,11 +176,6 @@ local function createCaster() newCaster:Init( Config.NumWorkers, - ReplicatedFirst, - Config.VMName, - ReplicatedFirst, - Config.VMContainerName, - Config.VMTemplateName, Config.MovementMode ) else @@ -241,24 +189,6 @@ local function createCaster() return newCaster end -local function applyObjectCache() - if not Caster then - return - end - - if not Config.ObjectCacheEnabled then - Caster:SetObjectCacheEnabled(false) - return - end - - Caster:SetObjectCacheEnabled( - true, - ProjectileTemplate, - Config.ProjectileAmount, - ProjectileContainer - ) -end - local function destroyCaster() if Caster then Caster:Destroy() @@ -272,12 +202,10 @@ local function rebuildCaster() destroyCaster() Caster = createCaster() - applyObjectCache() end -- Create initial caster. Caster = createCaster() -applyObjectCache() --// Benchmark Information @@ -286,12 +214,7 @@ local function printBenchmarkInfo() print(("Projectiles: %d"):format(Config.ProjectileAmount)) print(("Velocity: %.2f"):format(Config.Velocity)) - - print( - "ObjectCache: %s", - getObjectCacheState() and "Enabled" or "Disabled" - ) - + print(("CastType: %s"):format(Config.CastType)) print( "Instanced: %s", Config.Instanced and "Enabled" or "Disabled" @@ -313,55 +236,94 @@ local function printBenchmarkInfo() print(("MovementMode: %s"):format(Config.MovementMode)) print(("Mode: %s"):format(getMode())) + print(("InputMode: %s"):format(InputMode)) if Config.Parallel then print(("numWorkers: %d"):format(Config.NumWorkers)) end end ---// FPS Output +--// Benchmark Output + +local function percentile(sortedValues, fraction) + if #sortedValues == 0 then + return 0 + end -local function printFPS() - local minimum = FPS.Minimum + local index = math.max(1, math.ceil(#sortedValues * fraction)) + return sortedValues[index] +end - if minimum == math.huge then - minimum = 0 +local function printFrameStats() + if #FrameTimes == 0 then + print("No frames sampled.") + return end - local delta = FPS.Current > 0 - and (1000 / FPS.Current) - or 0 + local sorted = table.clone(FrameTimes) + table.sort(sorted) + + local total = 0 + for _, frameTime in FrameTimes do + total += frameTime + end - print(("Delta: %.2f ms"):format(delta)) - print(("Average FPS: %.2f"):format(FPS.Average)) - print(("Max FPS: %.2f"):format(FPS.Maximum)) - print(("Min FPS: %.2f"):format(minimum)) + local average = total / #FrameTimes + local slowest = sorted[#sorted] + local fastest = sorted[1] + + print(("Frames sampled: %d"):format(#FrameTimes)) + print(("Average frame time: %.2f ms (%.2f FPS)"):format(average, 1000 / average)) + print(("P50 / P95 / P99: %.2f / %.2f / %.2f ms"):format( + percentile(sorted, 0.50), + percentile(sorted, 0.95), + percentile(sorted, 0.99) + )) + print(("FPS range: %.2f - %.2f"):format(1000 / slowest, 1000 / fastest)) +end + +local function printOperationStats(elapsed, operationCount) + local elapsedMilliseconds = elapsed * 1000 + local throughput = elapsed > 0 and operationCount / elapsed or 0 + + print(("Elapsed: %.2f ms"):format(elapsedMilliseconds)) + print(("Throughput: %.2f projectiles/s"):format(throughput)) end --// Projectile Generation -local function fireProjectile() - Caster:RaycastFire( - Vector3.new( - math.random(-100, 100), - math.random(0, 100), - math.random(-100, 100) - ), - - Vector3.new( - math.random() * 2 - 1, - math.random() * 2 - 1, - math.random() * 2 - 1 - ), - - Config.Velocity, - CastBehavior +local function fireProjectile(random) + local origin = Vector3.new( + random:NextInteger(-100, 100), + random:NextInteger(0, 100), + random:NextInteger(-100, 100) + ) + local direction = Vector3.new( + random:NextNumber(-1, 1), + random:NextNumber(-1, 1), + random:NextNumber(-1, 1) ) + + if direction.Magnitude == 0 then + direction = Vector3.xAxis + else + direction = direction.Unit + end + + if Config.CastType == "Blockcast" then + Caster:BlockcastFire(origin, Config.BlockcastSize, direction, Config.Velocity, CastBehavior) + elseif Config.CastType == "Spherecast" then + Caster:SpherecastFire(origin, Config.SpherecastRadius, direction, Config.Velocity, CastBehavior) + else + Caster:RaycastFire(origin, direction, Config.Velocity, CastBehavior) + end end local function fireProjectiles() + local random = Random.new(Config.RandomSeed) + for _ = 1, Config.ProjectileAmount do - fireProjectile() + task.spawn(fireProjectile, random) end end @@ -379,6 +341,7 @@ local function cleanupCasts() end end + --// Benchmark local function benchmark() @@ -388,35 +351,102 @@ local function benchmark() IsBenchmarking = true - resetFPS() + print("=== BENCHMARK STARTED ===") + + local success, err = xpcall(function() + + printBenchmarkInfo() + print(("Firing %d projectiles..."):format(Config.ProjectileAmount)) - printBenchmarkInfo() - print(("Firing %d projectiles..."):format(Config.ProjectileAmount)) + -- Fire + local creationStarted = os.clock() - fireProjectiles() + fireProjectiles() - -- Allow the casts to begin processing. - task.wait() + local creationElapsed = os.clock() - creationStarted - printSeparator("CREATION COMPLETE") - printFPS() + task.wait() - task.wait(Config.BenchmarkDuration) + printSeparator("CREATION COMPLETE") + printOperationStats( + creationElapsed, + Config.ProjectileAmount + ) + + -- Measure frames + table.clear(FrameTimes) + + IsMeasuringFrames = true + + task.wait(Config.BenchmarkDuration) + + IsMeasuringFrames = false + + printSeparator("SIMULATION COMPLETE") + printFrameStats() + + -- Cleanup + printSeparator("CLEANUP") + + local cleanupStarted = os.clock() + + cleanupCasts() + + local cleanupElapsed = os.clock() - cleanupStarted + + printOperationStats( + cleanupElapsed, + Config.ProjectileAmount + ) + + -- Clear our references + table.clear(ActiveCasts) - printSeparator("SIMULATION COMPLETE") - printFPS() + -- Wait for FastCast to settle + task.wait(BENCHMARK_COOLDOWN) - printSeparator("CLEANUP") + -- IMPORTANT: + -- Destroy the old caster and create a fresh one. + print("Rebuilding caster...") - cleanupCasts() + rebuildCaster() - -- Give FastCast/object cache time to settle. - task.wait(BENCHMARK_COOLDOWN) + print("Caster rebuilt.") - printSeparator("DONE") - printFPS() + printSeparator("DONE") + end, debug.traceback) + + -- Always reset benchmark state + IsMeasuringFrames = false IsBenchmarking = false + + if not success then + warn("Benchmark error:") + warn(err) + + -- Also recover from an error by creating a fresh caster + task.defer(function() + rebuildCaster() + print("Caster recovered after benchmark error.") + end) + end + + print("IsBenchmarking =", IsBenchmarking) + print("=== BENCHMARK READY ===") +end + + +local function cycleCastType() + if Config.CastType == "Raycast" then + Config.CastType = "Blockcast" + elseif Config.CastType == "Blockcast" then + Config.CastType = "Spherecast" + else + Config.CastType = "Raycast" + end + + print(("CastType: %s"):format(Config.CastType)) end --// Configuration Changes @@ -451,18 +481,6 @@ local function toggleMovementMode() print(("MovementMode: %s"):format(Config.MovementMode)) end -local function toggleObjectCache() - Config.ObjectCacheEnabled = not Config.ObjectCacheEnabled - - applyObjectCache() - - print( - ("ObjectCache: %s"):format( - Config.ObjectCacheEnabled and "Enabled" or "Disabled" - ) - ) -end - local function toggleVisualization() CastBehavior.VisualizeCasts = not CastBehavior.VisualizeCasts @@ -525,6 +543,16 @@ end --// Mode Switching +local function switchInputMode() + if IsBenchmarking or IsReconfiguring then + return + end + + InputMode = if InputMode == "Client" then "Server" else "Client" + + print(("InputMode: %s"):format(InputMode)) +end + local function switchMode() if IsBenchmarking or IsReconfiguring then return @@ -587,12 +615,13 @@ local function showControls() print("K - Decrease projectile amount by 500") print("U - Toggle Instanced / Instanceless") print("J - Toggle BulkMoveTo / Motor6D") - print("Y - Toggle ObjectCache") print("V - Toggle VisualizeCasts") print("X - Cycle HighFidelityBehavior") print("> - Increase HighFidelitySegmentSize") print("< - Decrease HighFidelitySegmentSize") print("B - Toggle Serial / Parallel") + print("N - Toggle Client / Server input mode") + print("R - Cycle Raycast / Blockcast / Spherecast") print("[ - Decrease numWorkers") print("] - Increase numWorkers") print("I - Show benchmark info") @@ -628,12 +657,6 @@ local Keybinds = { end end, - [Enum.KeyCode.Y] = function() - if not IsBenchmarking and not IsReconfiguring then - toggleObjectCache() - end - end, - [Enum.KeyCode.V] = function() if not IsBenchmarking then toggleVisualization() @@ -648,6 +671,13 @@ local Keybinds = { [Enum.KeyCode.B] = switchMode, + + [Enum.KeyCode.R] = function() + if not IsBenchmarking then + cycleCastType() + end + end, + [Enum.KeyCode.LeftBracket] = function() changeWorkerCount(-1) end, @@ -683,16 +713,32 @@ local Keybinds = { --// Input +local function sendInputToServer(keyCode) + BenchmarkPackets.Input.send(keyCode.Name) +end + UserInputService.InputBegan:Connect(function(input, gameProcessed) if gameProcessed then return end + if input.KeyCode == INPUT_MODE_TOGGLE_KEY then + switchInputMode() + return + end + local callback = Keybinds[input.KeyCode] - if callback then - callback() + if not callback then + return end + + if InputMode == "Server" then + sendInputToServer(input.KeyCode) + return + end + + callback() end) --// Startup diff --git a/benchmarks/BenchmarkPackets.luau b/benchmarks/BenchmarkPackets.luau new file mode 100644 index 00000000..022ebd6a --- /dev/null +++ b/benchmarks/BenchmarkPackets.luau @@ -0,0 +1,17 @@ +--// Services + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +--// Modules + +local ByteNet = require(ReplicatedStorage:WaitForChild("Packages"):WaitForChild("ByteNet")) + +--// Packets + +return ByteNet.defineNamespace("FastCast2Benchmarks", function() + return { + Input = ByteNet.definePacket({ + value = ByteNet.string, + }), + } +end) diff --git a/benchmarks/BenchmarkServer.server.luau b/benchmarks/BenchmarkServer.server.luau index 799947e5..cfa564d3 100644 --- a/benchmarks/BenchmarkServer.server.luau +++ b/benchmarks/BenchmarkServer.server.luau @@ -12,8 +12,8 @@ k - -500 projectiles u - Toggle Instanced / Instanceless j - Toggle BulkMoveTo / Motor6D - y - Toggle ObjectCache v - Toggle Cast Visualization + r - Cycle cast type x - Cycle HighFidelityBehavior > - Increase HighFidelitySegmentSize < - Decrease HighFidelitySegmentSize @@ -37,18 +37,17 @@ local Config = { Instanced = false, MovementMode = "BulkMoveTo", - ObjectCacheEnabled = false, ProjectileAmount = 500, VisualizeCasts = false, + CastType = "Raycast", + RandomSeed = 2026, + BlockcastSize = Vector3.one, + SpherecastRadius = 0.5, Velocity = 6, BenchmarkDuration = 6, - VMName = "CastVMs", - VMContainerName = "CastVMContainer", - VMTemplateName = "CastVM", - ProjectileStep = 500, WorkerStep = 1, @@ -71,6 +70,10 @@ local FastCastEnums = require( FastCastModule:WaitForChild("FastCastEnums") ) +local BenchmarkPackets = require( + ReplicatedStorage:WaitForChild("BenchmarkPackets") +) + --// Projectile Objects local ProjectileContainer = Instance.new("Folder") @@ -125,48 +128,15 @@ local ActiveCasts = {} local IsBenchmarking = false local IsReconfiguring = false ---// FPS Tracking - -local FPS = { - Current = 0, - Average = 0, - Minimum = math.huge, - Maximum = 0, - - Accumulator = 0, - FrameCount = 0, -} - -local function resetFPS() - FPS.Current = 0 - FPS.Average = 0 - FPS.Minimum = math.huge - FPS.Maximum = 0 - - FPS.Accumulator = 0 - FPS.FrameCount = 0 -end - -local function updateFPS(dt) - if dt <= 0 then - return - end - - local current = 1 / dt - - FPS.Current = current - FPS.Minimum = math.min(FPS.Minimum, current) - FPS.Maximum = math.max(FPS.Maximum, current) +--// Frame Statistics - FPS.Accumulator += current - FPS.FrameCount += 1 - - -- Keep a continuously updated average. - FPS.Average = FPS.Accumulator / FPS.FrameCount -end +local FrameTimes = {} +local IsMeasuringFrames = false RunService.Heartbeat:Connect(function(dt) - updateFPS(dt) + if IsMeasuringFrames and dt > 0 then + FrameTimes[#FrameTimes + 1] = dt * 1000 + end end) --// Utility @@ -183,22 +153,6 @@ local function getHighFidelityName() end end -local function getObjectCacheState() - if not Caster then - return false - end - - if Caster.ObjectCacheEnabled then - return true - end - - if Caster.BaseCast and Caster.BaseCast.ObjectCacheInstance then - return true - end - - return false -end - local function getModeName() return Config.Instanced and "Instanced" or "Instanceless" end @@ -220,11 +174,6 @@ local function createCaster() caster:Init( Config.NumWorkers, - ServerScriptService, - Config.VMName, - ServerScriptService, - Config.VMContainerName, - Config.VMTemplateName, Config.MovementMode ) @@ -233,24 +182,6 @@ local function createCaster() return caster end -local function applyObjectCache() - if not Caster then - return - end - - if not Config.ObjectCacheEnabled then - Caster:SetObjectCacheEnabled(false) - return - end - - Caster:SetObjectCacheEnabled( - true, - ProjectileTemplate, - Config.ProjectileAmount, - ProjectileContainer - ) -end - --// Cast Cleanup local function terminateActiveCasts() @@ -263,8 +194,11 @@ local function terminateActiveCasts() ActiveCasts[i] = nil end + + table.clear(ActiveCasts) end + local function destroyCaster() if not Caster then return @@ -282,7 +216,6 @@ local function rebuildCaster() destroyCaster() Caster = createCaster() - applyObjectCache() end --// Benchmark Information @@ -298,6 +231,8 @@ local function printBenchmarkInfo() Config.Velocity )) + print(("CastType: %s"):format(Config.CastType)) + print(("ObjectCache: %s"):format( getObjectCacheState() and "Enabled" or "Disabled" )) @@ -327,53 +262,87 @@ local function printBenchmarkInfo() )) end ---// FPS Output +--// Benchmark Output -local function printFPS() - local minimum = FPS.Minimum +local function percentile(sortedValues, fraction) + if #sortedValues == 0 then + return 0 + end - if minimum == math.huge then - minimum = 0 + local index = math.max(1, math.ceil(#sortedValues * fraction)) + return sortedValues[index] +end + +local function printFrameStats() + if #FrameTimes == 0 then + print("No frames sampled.") + return end - local delta = FPS.Current > 0 - and (1000 / FPS.Current) - or 0 + local sorted = table.clone(FrameTimes) + table.sort(sorted) + + local total = 0 + for _, frameTime in FrameTimes do + total += frameTime + end - print(("Delta: %.2f ms"):format(delta)) - print(("Average FPS: %.2f"):format(FPS.Average)) - print(("Max FPS: %.2f"):format(FPS.Maximum)) - print(("Min FPS: %.2f"):format(minimum)) + local average = total / #FrameTimes + local slowest = sorted[#sorted] + local fastest = sorted[1] + + print(("Frames sampled: %d"):format(#FrameTimes)) + print(("Average frame time: %.2f ms (%.2f FPS)"):format(average, 1000 / average)) + print(("P50 / P95 / P99: %.2f / %.2f / %.2f ms"):format( + percentile(sorted, 0.50), + percentile(sorted, 0.95), + percentile(sorted, 0.99) + )) + print(("FPS range: %.2f - %.2f"):format(1000 / slowest, 1000 / fastest)) end ---// Projectile Generation +local function printOperationStats(elapsed, operationCount) + local elapsedMilliseconds = elapsed * 1000 + local throughput = elapsed > 0 and operationCount / elapsed or 0 -local function randomDirection() - return Vector3.new( - math.random() * 2 - 1, - math.random() * 2 - 1, - math.random() * 2 - 1 - ).Unit + print(("Elapsed: %.2f ms"):format(elapsedMilliseconds)) + print(("Throughput: %.2f projectiles/s"):format(throughput)) end -local function fireProjectile() +--// Projectile Generation + +local function fireProjectile(random) local origin = Vector3.new( - math.random(-100, 100), - math.random(0, 100), - math.random(-100, 100) + random:NextInteger(-100, 100), + random:NextInteger(0, 100), + random:NextInteger(-100, 100) ) - - Caster:RaycastFire( - origin, - randomDirection(), - Config.Velocity, - CastBehavior + local direction = Vector3.new( + random:NextNumber(-1, 1), + random:NextNumber(-1, 1), + random:NextNumber(-1, 1) ) + + if direction.Magnitude == 0 then + direction = Vector3.xAxis + else + direction = direction.Unit + end + + if Config.CastType == "Blockcast" then + Caster:BlockcastFire(origin, Config.BlockcastSize, direction, Config.Velocity, CastBehavior) + elseif Config.CastType == "Spherecast" then + Caster:SpherecastFire(origin, Config.SpherecastRadius, direction, Config.Velocity, CastBehavior) + else + Caster:RaycastFire(origin, direction, Config.Velocity, CastBehavior) + end end local function fireProjectiles() + local random = Random.new(Config.RandomSeed) + for _ = 1, Config.ProjectileAmount do - fireProjectile() + task.spawn(fireProjectile, random) end end @@ -385,38 +354,107 @@ local function runBenchmark() end IsBenchmarking = true + IsMeasuringFrames = false - resetFPS() + local success, err = xpcall(function() - printBenchmarkInfo() + printSection("BENCHMARK START") + printBenchmarkInfo() - print(("Firing %d projectiles..."):format( - Config.ProjectileAmount - )) + print(("Firing %d projectiles..."):format( + Config.ProjectileAmount + )) - fireProjectiles() + --// Creation + local creationStarted = os.clock() - -- Let the first batch begin processing. - task.wait() + fireProjectiles() - printSection("CREATION COMPLETE") - printFPS() + local creationElapsed = os.clock() - creationStarted - task.wait(Config.BenchmarkDuration) + -- Let Parallel FastCast begin processing. + task.wait() - printSection("SIMULATION COMPLETE") - printFPS() + printSection("CREATION COMPLETE") + printOperationStats( + creationElapsed, + Config.ProjectileAmount + ) - printSection("CLEANUP") + --// Simulation + table.clear(FrameTimes) - terminateActiveCasts() + IsMeasuringFrames = true + + task.wait(Config.BenchmarkDuration) + + IsMeasuringFrames = false + + printSection("SIMULATION COMPLETE") + printFrameStats() + + --// Cleanup + printSection("CLEANUP") + + local cleanupStarted = os.clock() + + terminateActiveCasts() + + local cleanupElapsed = os.clock() - cleanupStarted + + printOperationStats( + cleanupElapsed, + Config.ProjectileAmount + ) + + -- Give FastCast2's parallel workers time to finish + -- processing the terminated casts. + task.wait(BENCHMARK_COOLDOWN) + + --// IMPORTANT + -- Destroy the old parallel caster and create a + -- completely fresh one for the next benchmark. + print("Rebuilding FastCast caster...") + + rebuildCaster() - task.wait(BENCHMARK_COOLDOWN) + print("FastCast caster rebuilt.") - printSection("DONE") - printFPS() + printSection("DONE") + end, debug.traceback) + + -- Always restore benchmark state. + IsMeasuringFrames = false IsBenchmarking = false + + if not success then + warn("FastCast2 benchmark failed:") + warn(err) + + -- Recover the caster if something failed during + -- the benchmark. + pcall(function() + rebuildCaster() + end) + + print("FastCast caster recovered.") + end + + print("Benchmark ready. Type 'p' to run again.") +end + + +local function cycleCastType() + if Config.CastType == "Raycast" then + Config.CastType = "Blockcast" + elseif Config.CastType == "Blockcast" then + Config.CastType = "Spherecast" + else + Config.CastType = "Raycast" + end + + print(("CastType: %s"):format(Config.CastType)) end --// Configuration @@ -455,19 +493,6 @@ local function toggleMovementMode() )) end -local function toggleObjectCache() - Config.ObjectCacheEnabled = - not Config.ObjectCacheEnabled - - applyObjectCache() - - print(("ObjectCache: %s"):format( - Config.ObjectCacheEnabled - and "Enabled" - or "Disabled" - )) -end - local function toggleVisualization() Config.VisualizeCasts = not Config.VisualizeCasts @@ -572,8 +597,8 @@ local function showHelp() print("k - Decrease projectile amount by 500") print("u - Toggle Instanced / Instanceless") print("j - Toggle BulkMoveTo / Motor6D") - print("y - Toggle ObjectCache") print("v - Toggle VisualizeCasts") + print("r - Cycle Raycast / Blockcast / Spherecast") print("x - Cycle HighFidelityBehavior") print("> - Increase HighFidelitySegmentSize") print("< - Decrease HighFidelitySegmentSize") @@ -612,15 +637,15 @@ local Commands = { end end, - y = function() - if not IsBenchmarking and not IsReconfiguring then - toggleObjectCache() + v = function() + if not IsBenchmarking then + toggleVisualization() end end, - v = function() + r = function() if not IsBenchmarking then - toggleVisualization() + cycleCastType() end end, @@ -671,11 +696,30 @@ local Commands = { h = showHelp, } ---// Chat Handling -local function onChatMessage(player, message) - local command = message:lower():gsub("^%s*(.-)%s*$", "%1") +--// Input Handling + +-- Maps Enum.KeyCode names (sent by the client) to Commands keys. +local KeyNameToCommand = { + P = "p", + L = "l", + K = "k", + U = "u", + J = "j", + Y = "y", + V = "v", + X = "x", + R = "r", + I = "i", + H = "h", + + Period = ">", + Comma = "<", + LeftBracket = "[", + RightBracket = "]", +} +local function runCommand(command) local handler = Commands[command] if handler then @@ -683,17 +727,25 @@ local function onChatMessage(player, message) end end -local function connectPlayer(player) - player.Chatted:Connect(function(message) - onChatMessage(player, message) - end) -end +--// ByteNet (client key input) -Players.PlayerAdded:Connect(connectPlayer) +BenchmarkPackets.Input.listen(function(keyName) + if typeof(keyName) ~= "string" then + return + end -for _, player in Players:GetPlayers() do - connectPlayer(player) -end + -- The server benchmark is parallel-only, so B has no effect here. + if keyName == "B" then + print("Serial / Parallel toggle isn't available on the server (Parallel only).") + return + end + + local command = KeyNameToCommand[keyName] + + if command then + runCommand(command) + end +end) --// Initialization diff --git a/benchmarks/README.md b/benchmarks/README.md index a8378c3c..3a0ad1da 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -17,8 +17,8 @@ Fires projectiles and tracks FPS using keyboard controls. Supports both serial a | K | Decrease projectile count by 500 (floor 0) | | U | Toggle Instanced / Instanceless | | J | Toggle BulkMoveTo / Motor6D | -| Y | Toggle ObjectCache | | V | Toggle VisualizeCasts | +| R | Cycle Raycast / Blockcast / Spherecast | | X | Cycle HighFidelityBehavior (Default > Automatic > Always) | | > | Increase HighFidelitySegmentSize by 0.05 | | < | Decrease HighFidelitySegmentSize by 0.05 (floor 0.05) | @@ -35,14 +35,16 @@ Fires projectiles and tracks FPS using keyboard controls. Supports both serial a Edit at the top of the file: ```lua -local Parallel = true -- true = parallel, false = serial -local numWorkers = 4 -- number of VM workers (parallel only) -local ProjectileAmount = 500 -- projectiles per benchmark run -local Instanced = true -- use cosmetic bullet instances -local VisualizeCasts = false -- show cast ray visualization -local BENCH_TIME = 6 -- seconds to simulate after creation -local MovementMode = "BulkMoveTo" -local ObjectCacheEnabled = false +local Config = { + Parallel = true, -- true = parallel, false = serial + NumWorkers = 4, -- number of VM workers (parallel only) + ProjectileAmount = 500, -- projectiles per benchmark run + CastType = "Raycast", -- Raycast, Blockcast, or Spherecast + Instanced = true, -- use cosmetic bullet instances + VisualizeCasts = false, -- show cast ray visualization + BenchmarkDuration = 6, -- seconds to simulate after creation + MovementMode = "BulkMoveTo", +} ``` --- @@ -64,8 +66,8 @@ Type the letter in chat: | k | Decrease projectile count by 500 (floor 0) | | u | Toggle Instanced / Instanceless | | j | Toggle BulkMoveTo / Motor6D | -| y | Toggle ObjectCache | | v | Toggle VisualizeCasts | +| r | Cycle Raycast / Blockcast / Spherecast | | x | Cycle HighFidelityBehavior | | > | Increase HighFidelitySegmentSize by 0.05 | | < | Decrease HighFidelitySegmentSize by 0.05 | @@ -79,13 +81,15 @@ Type the letter in chat: Edit at the top of the file: ```lua -local numWorkers = 4 -local Instanced = false -local MovementMode = "BulkMoveTo" -local UseObjectCache = false -local ProjectileAmount = 500 -local VisualizeCasts = false -local VELOCITY = 6 +local Config = { + NumWorkers = 4, + Instanced = false, + MovementMode = "BulkMoveTo", + ProjectileAmount = 500, + CastType = "Raycast", + VisualizeCasts = false, + Velocity = 6, +} ``` --- @@ -96,33 +100,48 @@ Both scripts print the same format after each run: ``` === MODE BENCHMARK === -ObjectCache Disabled VisualizeCasts: Disabled HighFidelityBehavior: Default MovementMode: BulkMoveTo +CastType: Raycast Instanceless numWorkers: 4 Firing 500 projectiles... === CREATION COMPLETE === -Delta: xx.xx ms -Average FPS: xx.xx -Max FPS: xx.xx -Min FPS: xx.xx +Elapsed: xx.xx ms +Throughput: xx.xx projectiles/s === SIMULATION COMPLETE === -... +Frames sampled: xxx +Average frame time: xx.xx ms (xx.xx FPS) +P50 / P95 / P99: xx.xx / xx.xx / xx.xx ms +FPS range: xx.xx - xx.xx +=== CLEANUP === +Elapsed: xx.xx ms +Throughput: xx.xx projectiles/s === DONE === -Delta: xx.xx ms -Average FPS: xx.xx -Max FPS: xx.xx -Min FPS: xx.xx ``` -- **Delta**: Frame time in milliseconds (lower is better) -- **FPS** values are collected per-frame and averaged over 0.5s windows -- `maxFps`/`minFps` reset at the start of each benchmark run +- **Elapsed / throughput** measure synchronous projectile creation and cleanup work. +- **Average frame time** is calculated from every heartbeat during the simulation window; FPS is derived from that average. +- **P50 / P95 / P99** expose frame-time spikes that an FPS average can hide (lower is better). +- Each run uses the same random seed, so different modes and settings receive identical projectile origins and directions. ## Quick Start -1. Insert BenchmarkClient into ReplicatedFirst (or StarterPlayerScripts) -2. Insert BenchmarkServer into ServerScriptService -3. Play in Studio -- press P on the client or type p in chat on the server \ No newline at end of file +1. Run `rojo serve benchmarks.project.json` from the repository root. +2. Connect an empty Studio place with the Rojo plugin. +3. Play in Studio -- press P on the client or type p in chat on the server. + +The benchmark project mounts FastCast2 in `ReplicatedStorage`, the client benchmark in `ReplicatedFirst`, and the server benchmark in `ServerScriptService`. You can also insert either script manually in those locations if you do not use Rojo. + +For useful comparisons, change one setting at a time and run each configuration several times. In particular, compare all three cast types in serial and parallel modes with visualization disabled; Studio rendering, open windows, and plugins can otherwise add significant noise. + +## Automated suite + +The repeatable six-case matrix (Serial/Parallel x Raycast/Blockcast/Spherecast) +now lives with the rest of the automated tests. It builds a Rojo place, runs it +in Studio with `run-in-roblox`, prints a Markdown results table, and uploads the +report to [paste.shellworks.dev](https://paste.shellworks.dev). See +[`tests/README.md`](../tests/README.md) for prerequisites and usage; run it with +`npm test` or `lune run tests/run.luau`. + diff --git a/benchmarks/wally.toml b/benchmarks/wally.toml new file mode 100644 index 00000000..7c2b8064 --- /dev/null +++ b/benchmarks/wally.toml @@ -0,0 +1,9 @@ +[package] +name = "mawin/benchmark" +description = "Some benchmarking for FastCast2" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] +ByteNet = "ffrostflame/bytenet@0.4.6" diff --git a/docs/api-reference.md b/docs/api-reference.md index e7a9db65..10ff1761 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -8,46 +8,46 @@ Serial Caster runs all cast simulations on the main thread. Simpler. ```lua local caster = FastCast2.new() -caster:Init(movementMode, useObjectCache, template, cacheSize, cacheHolder) +caster:Init(movementMode) ``` -#### 1.1.1 Parameters +#### 1.1.1 Events -| Parameter | Type | Description | -|-----------|------|-------------| -| `movementMode` | `"BulkMoveTo" \| "Motor6D"` | How cosmetic bullets are moved each frame | -| `useObjectCache` | `boolean` | Enable part pooling via ObjectCache | -| `Template` | `BasePart \| Model?` | Template for ObjectCache | -| `CacheSize` | `number?` | Pre-allocated cache size (default 500) | -| `CacheHolder` | `Instance?` | Parent for cached objects (default workspace) | +Caster events are lightweight `Signal` objects (not `BindableEvent`s), so any +number of listeners can connect and disconnect independently. Events can be +connected **before or after Init**: -#### 1.1.2 Events +```lua +local connection = caster.Hit:Connect(function(cast, result, velocity, cosmeticBullet) end) +connection:Disconnect() -- stop listening + +caster.Pierced:Connect(function(cast, result, velocity, cosmeticBullet) end) +caster.LengthChanged:Connect(function(cast, lastPoint, rayDir, rayDisplacement, velocity, cosmeticBullet) end) +caster.CastFire:Connect(function(cast, origin, direction, velocity, behavior) end) +caster.CastTerminating:Connect(function(cast) end) +``` -Events are assigned directly on the caster **before or after Init**: +`CanPierce` is a query that must return a boolean, so it stays a single function: ```lua -caster.Hit = function(cast, result, velocity, cosmeticBullet) end -caster.Pierced = function(cast, result, velocity, cosmeticBullet) end -caster.LengthChanged = function(cast, lastPoint, rayDir, rayDisplacement) end -caster.CastFire = function(cast, origin, direction, velocity, behavior) end -caster.CastTerminating = function(cast) end caster.CanPierce = function(cast, result, velocity, cosmeticBullet) -> boolean end ``` -#### 1.1.3 Movement Modes +For backwards compatibility, assigning a function to a signal event is shorthand +for `:Connect` and replaces any previous function assigned this way: -- **`"BulkMoveTo"`** — Uses `workspace:BulkMoveTo()` each frame. (General/Default) -- **`"Motor6D"`** — Uses Motor6D instances (Transform property). Better for performance. +```lua +caster.Hit = function(cast, result, velocity, cosmeticBullet) end +``` -Switch modes at runtime with `caster:SetMovementMode(mode)`. +`Signal` supports `Connect`, `Once`, `Wait`, `Fire`, `DisconnectAll`, and `Destroy`. -#### 1.1.4 ObjectCache +#### 1.1.3 Movement Modes -ObjectCache reuses projectile parts: +- **`"BulkMoveTo"`** — Uses `workspace:BulkMoveTo()` each frame. (General/Default) +- **`"Motor6D"`** — Uses Motor6D instances (Transform property). Better for performance. -```lua -caster:Init("BulkMoveTo", true, projectileTemplate, 500, workspace) -``` +Switch modes at runtime with `caster:SetMovementModeEnabled(enabled, mode)`. --- @@ -58,18 +58,9 @@ Parallel Caster runs cast simulations on separate Actor VMs. ```lua local caster = FastCast2.newParallel() caster:Init( - numWorkers, -- number of worker VMs (must be > 1) - newParent, -- Folder to place FastCastVMs - newName, -- name for FastCastVMs folder - ContainerParent, -- parent for worker containers - VMContainerName, -- name for containers - VMname, -- name for each worker VM + numWorkers, -- number of worker VMs (must be at least 1) movementMode, -- "BulkMoveTo" or "Motor6D" - fastCastEventsModule,-- optional ModuleScript - useObjectCache, -- enable ObjectCache - template, -- ObjectCache template - cacheSize, -- ObjectCache size - CacheHolder -- ObjectCache parent + fastCastEventsModule -- optional ModuleScript ) ``` > [!NOTE] @@ -81,18 +72,9 @@ caster:Init( | Parameter | Type | Description | |-----------|------|-------------| -| `numWorkers` | `number` | Number of Actor VMs. Must be > 1. | -| `newParent` | `Folder` | Parent for the FastCastVMs Folder | -| `newName` | `string` | Name for the FastCastVMs Folder | -| `ContainerParent` | `Folder` | Parent for worker VM Containers | -| `VMContainerName` | `string` | Name for VM Containers | -| `VMname` | `string` | Name given to each worker VM | -| `movementMode` | `"BulkMoveTo" \| "Motor6D"` | Movement method | -| `fastCastEventsModule` | `ModuleScript?` | FastCastEvents module | -| `useObjectCache` | `boolean` | Enable ObjectCache | -| `template` | `BasePart \| Model?` | ObjectCache template | -| `cacheSize` | `number?` | ObjectCache size | -| `CacheHolder` | `Instance?` | ObjectCache parent | +| `numWorkers` | `number` | Number of worker VMs. Must be at least 1. | +| `movementMode` | `"BulkMoveTo" | "Motor6D"` | Movement method | +| `fastCastEventsModule` | `ModuleScript?` | Optional FastCastEvents module | #### 1.2.2 How It Works @@ -105,7 +87,7 @@ caster:Init( ```lua caster:SetFastCastEventsModule(moduleScript) -- Parallel only caster:SetMovementModeEnabled(enabled, mode) -caster:SetObjectCacheEnabled(enabled, template?, cacheSize?, cacheHolder?) + ``` --- @@ -218,6 +200,7 @@ behavior.VisualizeCastSettings = { | `AutoIgnoreContainer` | `boolean` | `true` | Auto-adds container to filter list | | `VisualizeCasts` | `boolean` | `false` | Debug visualization toggle | | `VisualizeCastSettings` | `table` | (defaults) | Debug viz colors, sizes, lifetimes | +| `CloneTemplate` | `boolean` | `true` | Clone CosmeticBulletTemplate or not | | `UserData` | `any` | `nil` | Arbitrary data accessible on the cast | ### Event Configuration diff --git a/docs/changelog.md b/docs/changelog.md index e201b3da..dd4eda0c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,146 +1,82 @@ --- -sidebar_position: 3 ---- + +## sidebar_position: 3 # Changelog All notable changes to this project will be documented in this file. -The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [0.1.0] — 2026-05-07 - -### Added -- **Serial Mode** (`FastCast.new()`) - Main thread projectile simulation, simpler API -- **Parallel Mode** (`FastCast.newParallel()`) - Worker VM based parallel simulation -- **Motor6D movement mode** - New movement method using Motor6D for better performance - - Pass `"Motor6D"` as the movement mode to `caster:Init()` -- **SerialSimulation** - Single RunService with SoA pattern for Serial casts -- **ParallelSimulation** - Per-Actor SoA pattern for Parallel casts -- **Motor6DCache** - Object pooling for Motor6D instances - -### Changed -- NONE - -### Fixed -- **HighFidelityBehavior = 2 bug** - Fixed subRayDir calculation using `delta` instead of `timeIncrement` - -### Performance -- Serial: 1 global RunService handling all casts with SoA arrays -- Parallel: 1 RunService per Actor with SoA arrays within each +> **Version support** +> +> FastCast2 **1.0.0 and newer** are the current and supported versions. +> +> Versions **<1.0.0** are legacy releases. They are no longer actively maintained or documented, but remain available through GitHub Releases for existing projects and historical use. --- -## [0.0.9] — 2026-03-03 - -### Changed -- Refactored ActiveCast.luau -- Merged ActiveBlockcast.luau and ActiveSpherecast.luau with ActiveCast.luau -- Updated TypeDef, Enums, FastCastVMs -- Removed FastCast:SafeCall(func, ...) -- Changed CFrame.new() to CFrame.new(origin) in ActiveCast.luau - -### Fixed -- Spherecast not working -- Type errors -- Typo fixes -- No longer errors now when attempting to index with FastCastEvents with guarding -- Fix CanPierce logic and unnecessary things -- Fix unnamed parameters in all callback function types -- Fix incorrect union types on Caster signal fields (removed RBXScriptSignal, RBXScriptConnection) -- Fix GetVelocityCast and AddAccelerationCast signatures -- Fix SphereCastRayInfo `@type` doc copying BlockCastRayInfo -- Fix OnCastFireFunction `@type` unnamed parameters -- Remove stale RBXScriptSignal references from Caster `@type` doc -- Fixed GetAccelerationCast returning velocity instead of acceleration -- Fixed AddPositionCast, AddVelocityCast, AddAccelerationCast calling nonexistent methods -- Fixed missing return after cascading cast warn in SimulateCast and Stepped -- Fixed Destroy referencing RayHit/RayPierced instead of Hit/Pierced -- Fixed DBG_SEGMENT_SUB_COLOR2 being identical to DBG_SEGMENT_SUB_COLOR -- Fixed numWorkers assertion from > 1 to >= 1 -- Fixed BulkMoveTo double-connection guard in BindBulkMoveTo - -### Removed -- Removed unused SafeCall, material, and dead code - -### Improved -- Merged ResumeCast into PauseCast(cast, value) for simplicity -- Added missing SetPositionCast method -- Updated all doc comments to use vaildcast type consistently -- Add guarding for SetFastCastEventsModule -- Cached CastFire from require FastCastEventsModule result in SetFastCastEventsModule -- Removed unused variables in ActiveCast.luau and BaseCast.luau - -## [0.0.8] — 2026-02-21 +## [Unreleased] ### Added -- Spherecast feature — adds sphere-based casting for broader collision detection and hit testing. - -### Changed -- Blockcast visualization no longer stretched by cast length. -- Cleaned up code and performed minor refactors for readability. -- Updated documentation comments for clarity. -### Fixed -- Type errors -- FastCast2 now uses copy table instead of shared table for .newBehavior() - -## [0.0.7] - 2026-02-11 - -### Added -- Support for initial `UserData` on cast behaviors (`behavior.UserData`). -- Additional Caster interface methods to mirror ActiveCast/ActiveBlockcast functionality. - -### Changed -- Refactored core architecture into an Entity-Component-System (ECS) structure. -- Updated FastCast method calls to use object-style syntax (`FastCast:method()`). -- Renamed events for clarity: - - `RayHit` → `Hit` - - `CanRayPierce` → `CanPierce` - - `RayPierced` → `Pierced` -- Improved and corrected type definitions. -- Updated documentation comments for clarity and accuracy. +* Automated testing by @Naymmmm +* Better performance benchmarks by @Naymmmm +* `"Config.SimulationMode"` (PerFrame/Fixed) and `"CosmeticUpdateInterval"` by @Naymmmm +* `"RAY_SEARCH_OFFSET"` for pierce prevention +* `"cast.Mode"` +* **`Signal`** - lightweight synchronous event dispatcher (`src/Signal.luau`) used for Caster events instead of `BindableEvent` +* Caster events (`Hit`, `Pierced`, `LengthChanged`, `CastFire`, `CastTerminating`) are now Signals supporting multiple listeners, `Once`, `Wait`, `Disconnect`, `DisconnectAll`, and `Destroy` + +### Fixes + +* BenchmarkClient/BenchmarkServer +* Various simulation logic bugs +* `"CastTerminating"` callback/cleanup issues (Serial now invokes the user callback before cleanup) +* Pierce prevention issues (`"RAY_SEARCH_OFFSET"` only applies after a pierce) +* Motor6D movement issues +* Parallel client initialization (Init is now retried until every actor is ready) +* Refactor regressions found by the test suite +* An annoying warning +* Duplicate Serial cast unregister cleanup +* Parallel behavior snapshots now properly deep-copy nested configuration data + +### Changes + +* `"SerialSimulation"` is no longer OOP-based and no longer creates a per-instance connection. +* Replaced `"BindableEvent"` with Signal module (supports multiple listeners, function assignment still works) +* Simplified `"FastCastParallel:Init"` API +* Added `"BindToSimulation"` +* Consolidated `"TerminateCast"` +* Parallel fire requests are now batched into one message per worker per frame +* Improved Serial/Parallel event systems +* You can now `Caster.Event:Connect(function() ... end)` or `Caster.Event = function() ... end` +* Parallel workers now batch all queued events into a single `Output` message per frame instead of firing one message per event +* `CanPierce` remains a single function because it has to return a boolean by @Naymmmm +* A lot of optimizations +* Major code improvements +* Improved documentation ### Removed -- Removed `BetterLengthChangedModule`. -- Removed `CanRayPierceModule` (functionality replaced by FastCastEventsModule). -### Fixed -- Fixed crash in `Caster:Destroy()` when indexing nil components. -- Fixed module loading issues when required in parallel threads. -- Added safeguards to prevent indexing nil `FastCastEventsModule`. -- Added proper initialization checks to prevent premature Caster method usage. +* Built-in ObjectCache + (See: ---- - -## [0.0.6] - 2026-02-08 - -### Changed -- Internal structural improvements and refactoring for stability. -- Documentation refinements and cleanup. + * YouTube video: https://www.youtube.com/watch?v=YyQi82TzYL4&t=61s + * DevForum post: https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed-a-powerful-modern-projectile-library/4093890/544?u=mawin_ck + ) -### Fixed -- Various minor internal consistency fixes. +* Automatic `CosmeticBulletTemplate` cleanup + (So you can have more control over `CosmeticBulletTemplate`) ---- +### Cancelled -## [0.0.5] - 2026-01-31 +* Dynamic RunService event configuration for Caster + (Because you can simply edit the FastCast2 code if you want to change the specific RunService event) -### Added -- Introduced FastCast2 testing ground. -- Added benchmarking tools. -- Added AWPTestGround for projectile behavior testing. +* Debugger GUI for benchmarking and testing + (Might add this back) --- - -## [0.0.3] - 2026-01-03 - -- UNKNOWN - ---- - -## [0.0.1] - 2025-11-23 - -- UNKNOWN diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 41951c8b..fe14a3df 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -8,7 +8,7 @@ sidebar_position: 2 ```lua local caster = FastCast2.new() -caster:Init("BulkMoveTo", false) +caster:Init("BulkMoveTo") caster.Hit = function(cast, result, velocity, bullet) print("Hit:", result.Instance) @@ -25,11 +25,7 @@ caster:RaycastFire(origin, direction, 500, behavior) local Caster = FastCast2.new() -- Construct a new Serial Caster Caster:Init( - movementMode: "BulkMoveTo" | "Motor6D", -- Movement method for cosmetic bullets. - useObjectCache: boolean, -- Enable ObjectCache for this Caster. - Template: BasePart | Model?, -- Template for ObjectCache (if enabled). - CacheSize: number?, -- Number of objects to pre-allocate. - CacheHolder: Instance? -- Parent for cached objects. + movementMode: "BulkMoveTo" | "Motor6D" -- Movement method. ) -- ⚠ Must be called before any Fire methods — nothing happens without Init! @@ -58,7 +54,6 @@ Caster:SpherecastFire(origin, Radius, direction, velocity, BehaviorData) --// Configuration Caster:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") → () -Caster:SetObjectCacheEnabled(enabled: boolean, Template?, CacheSize?, CacheHolder?) → () --// Lifecycle @@ -76,18 +71,9 @@ Caster:Destroy() → () local Caster = FastCast2.newParallel() Caster:Init( - numWorkers: number, -- Number of Actor VMs. Must be > 1. - newParent: Folder, -- Parent for the FastCastVMs Folder. - newName: string, -- Name for the FastCastVMs Folder. - ContainerParent: Folder, -- Parent for worker VM Containers. - VMContainerName: string, -- Name for VM Containers. - VMname: string, -- Name given to each worker VM. + numWorkers: number, -- Number of Actor VMs. Must be at least 1. movementMode: "BulkMoveTo" | "Motor6D", -- Movement method. - FastCastEventsModule: ModuleScript?,-- ModuleScript returning a FastCastEvents table. - useObjectCache: boolean, -- Enable ObjectCache for this Caster. - Template: BasePart | Model?, -- Template for ObjectCache (if enabled). - CacheSize: number?, -- Number of objects to pre-allocate. - CacheHolder: Instance? -- Parent for cached objects. + FastCastEventsModule: ModuleScript? -- Optional event module. ) -- ⚠ Must be called before any Fire methods — nothing happens without Init! @@ -108,7 +94,6 @@ Caster:SpherecastFire(origin, Radius, direction, velocity, BehaviorData) Caster:SetFastCastEventsModule(moduleScript: ModuleScript) → () Caster:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") → () -Caster:SetObjectCacheEnabled(enabled, Template?, CacheSize?, CacheHolder?) → () --// Cast Manipulation (use FastCast static methods) diff --git a/docs/intro.md b/docs/intro.md index 93bc05a1..0f0a618c 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -40,7 +40,7 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith --- -# Benefits of using FastCast2 +# Features - Highly customizable - Versatile projectile library @@ -49,71 +49,70 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith - Raycast and Blockcast, Spherecast support - BulkMoveTo/Motor6D support - Built-in castVisualization -- Built-in ObjectCache - Built-in HighFidelitySegment control -- Flexible and extensible design -- Improves development productivity +- Flexible, extensible - High Performance -- Completely free -FastCast2 is an open-source project, and contributions from the community are welcome.
-Read more on [FastCast2 devforum](https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed/4093890) +FastCast2 is an open-source project, and contributions from the community are welcome — see [Contributing](#contributing) below. --- -# Installation guide +## Installation guide -1. Go to [Releases](https://github.com/weenachuangkud/FastCast2/releases) and install the `.rbxm` file from the latest release. +### 1. Model file (.rbxm) + +1. Go to [Releases](https://github.com/weenachuangkud/FastCast2/releases) and download the `.rbxm` file from the latest release. 2. Open Roblox Studio and open any project. 3. Go to **File → Import Roblox Model** and import the `.rbxm` file. 4. After importing, **FastCast2** will appear in your Workspace. 5. Drag **FastCast2** into **ReplicatedStorage**. -6. Done, and you’re ready to use FastCast2. - ---- - -## Install with [Wally](https://wally.run/) - -1. Make you have installed Wally: https://wally.run/install -2. After you've installed Wally, make sure to `wally init` -3. Then inside `wally.toml` below `[dependencies]` copy and paste `FastCast2 = "weenachuangkud/fastcast2@0.1.4"` -4. Run `wally install`, and you're ready to use - -NOTE: This does not include rojo setup +6. Done — you're ready to use FastCast2. + +### 2. Install with [Wally](https://wally.run/) + +1. Make sure you have [Wally installed](https://wally.run/install). +2. Run `wally init` if you haven't already. +3. Add FastCast2 under `[dependencies]` in your `wally.toml`: +```toml + FastCast2 = "weenachuangkud/fastcast2@0.1.4" +``` + +4. Run `wally install` and you're ready to use it. +> **Note:** This does not include a Rojo setup, you'll need your own `default.project.json` to sync `Packages` into your place. --- -# Code example - -Shooting projectiles from your head (Serial mode - simpler, main thread): - +## Quick start + +Shooting projectiles from your head (Serial mode, simpler, runs on the main thread): + ```lua -- Services local Rep = game:GetService("ReplicatedStorage") local Players = game:GetService("Players") - + -- Requires local FastCast2 = require(Rep:WaitForChild("FastCast2")) local FastCastEnums = require(Rep:WaitForChild("FastCast2"):WaitForChild("FastCastEnums")) - + -- CONSTANTS local SPEED = 500 - + -- Variables local player = Players.LocalPlayer local character = player.Character or player.CharacterAdded:Wait() local Head = character:WaitForChild("Head") local Mouse = player:GetMouse() - + local ProjectileContainer = workspace:WaitForChild("Projectiles") local ProjectileTemplate = Rep:WaitForChild("Projectile") - + -- CastParams local CastParams = RaycastParams.new() CastParams.FilterDescendantsInstances = {character} CastParams.FilterType = Enum.RaycastFilterType.Exclude CastParams.IgnoreWater = true - + -- Behavior (FastCastBehavior) local behavior = FastCast2.newBehavior() behavior.MaxDistance = 1000 @@ -123,24 +122,23 @@ behavior.HighFidelitySegmentSize = 1 behavior.Acceleration = Vector3.new(0, -workspace.Gravity/2.3, 0) behavior.CosmeticBulletContainer = ProjectileContainer behavior.CosmeticBulletTemplate = ProjectileTemplate - - + -- Serial Caster (runs on main thread, simpler) local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", false) -- movementMode, useObjectCache - +Caster:Init("BulkMoveTo") -- movementMode + -- Events (can be set before Init) Caster.Hit = function(cast, result, velocity, bullet) print("Hit: " .. result.Instance.Name) end - + -- Fire local function fire() local origin = Head.Position local direction = (Mouse.Hit.Position - origin).Unit Caster:RaycastFire(origin, direction, SPEED, behavior) end - + -- Input game:GetService("UserInputService").InputBegan:Connect(function(input, gameProcessed) if gameProcessed then return end @@ -151,63 +149,52 @@ end) ``` ### Blockcast & Spherecast - -Swap `RaycastFire` for `BlockcastFire` or `SpherecastFire` to change cast type: - + +Swap `RaycastFire` for `BlockcastFire` or `SpherecastFire` to change the cast type: + ```lua -- Blockcast: pass a Vector3 size after origin Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) - + -- Spherecast: pass a number radius after origin Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` -### ObjectCache (Or object pooling) +### Cosmetic bullets -ObjectCache reuses cosmetic bullet instances instead of creating/destroying them every shot: +FastCast2 no longer includes a built-in ObjectCache. Manage cosmetic bullet instances yourself through `behavior.CosmeticBulletTemplate` and `behavior.CosmeticBulletContainer`. -```lua -local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) --- ^template ^size ^holder -``` - -The cache pre-allocates 500 parts by default, auto-expands when exhausted, and moves retired -parts to a far-away CFrame via `BulkMoveTo` — no instance creation/destruction overhead. +FastCast2 does not automatically destroy or return cosmetic bullet instances when a cast terminates. Your game controls their lifecycle. ### Parallel mode + ```lua local Caster = FastCast2.newParallel() Caster:Init( 4, -- numWorkers - workspace, -- VM folder parent - "FastCastVMs", -- VM folder name - workspace, -- container parent - "VMContainer", -- container name - "VM", -- VM name "BulkMoveTo", -- movementMode nil, -- FastCastEventsModule (optional) - false -- useObjectCache ) - + -- Events work the same as serial Caster.Hit = function(cast, result, velocity, bullet) print("Hit: " .. result.Instance.Name) end - + --[[ -- Except: -- Caster.CanPierce --> You will have to use FastCastEventsModule for that ]] - + -- Fire the same as serial Caster:RaycastFire(origin, direction, SPEED, behavior) Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` +
How to set up [FastCastEventsModule](https://weenachuangkud.github.io/FastCast2/api/TypeDefinitions/#FastCastEventsModule) @@ -283,7 +270,7 @@ Motor6D mode uses `Motor6D.Transform` for more performance instead of `BulkMoveT ```lua local Caster = FastCast2.new() -Caster:Init("Motor6D", false) -- movementMode = "Motor6D" +Caster:Init("Motor6D") -- movementMode = "Motor6D" ``` All active casts automatically get a Motor6D connection on registration and disconnection on cleanup. @@ -294,6 +281,20 @@ Caster:SetMovementModeEnabled(true, "Motor6D") -- enable Motor6D Caster:SetMovementModeEnabled(true, "BulkMoveTo") -- switch back ``` +### Simulation configuration + +Configure simulation in `src/Config.luau`: + +```lua +Config.SimulationMode = "PerFrame" -- or "Fixed" +Config.FixedSimulationFrequency = Enum.StepFrequency.Hz60 +Config.CosmeticUpdateInterval = 3 +``` + +- `PerFrame`: `PreSimulation` on the client and `Heartbeat` on the server. +- `Fixed`: uses `RunService:BindToSimulation()` at the configured frequency. +- `CosmeticUpdateInterval`: controls cosmetic movement frequency. Collision simulation still runs every simulation step. + ### Cast manipulation Modify active casts at runtime using the static `FastCast` methods: @@ -322,11 +323,33 @@ FastCast2:TerminateCast(cast) ### -> Get started with the [FastCast2 documentation](https://weenachuangkud.github.io/FastCast2/docs/api-reference) +--- + +## Contributing + +FastCast2 is community-driven, and contributions of all kinds are welcome, code, documentation, bug reports, examples, or ideas. + +1. Found a bug or have an idea? Open an [Issue](https://github.com/weenachuangkud/FastCast2/issues) first. +2. Want to submit code or docs? Fork the repo, branch off `main`, make your changes, and open a Pull Request. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full guide and [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) for community expectations. + +
+ + +## Benchmarks + +FastCast2 includes repeatable client and server benchmarks for raycasts, blockcasts, and spherecasts. They compare serial and parallel execution using creation/cleanup throughput and simulation frame-time percentiles. See the [benchmark guide](benchmarks/README.md) for controls and the ready-to-sync Rojo project. + +## Tests + +The automated suite runs the functional tests and the six-case benchmark matrix inside Roblox Studio, prints a Markdown results table, and uploads the report to [paste.shellworks.dev](https://paste.shellworks.dev). Run it with `npm test` (or `lune run tests/run.luau`). See the [test guide](tests/README.md) for prerequisites and flags. + + --- # People behind FastCast2(Contributors) - [CK06](https://github.com/weenachuangkud): Main developer, Maintainer, Graphic designer -- [Naymmmm](https://github.com/Naymmmm): Maintainer (Inactive) +- [Naymmmm](https://github.com/Naymmmm): Maintainer - [EtiTheSpirit](https://github.com/EtiTheSpirit): Original developer - [Per2iako](https://github.com/Per2iako): Maintainer @@ -348,5 +371,4 @@ More broadly, this community feedback has played a significant role in FastCast2 - [90gq31](https://computerunion.miraheze.org/90gq31) — Giving me the opportunity to be part of the community # Dependency -- [ObjectCache](https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112) -- [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher) +- [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher)(https://github.com/weenachuangkud/VMsDispatcher) diff --git a/package.json b/package.json index dfe17d20..0474a96b 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,9 @@ { + "scripts": { + "test": "lune run tests/run.luau", + "test:no-upload": "lune run tests/run.luau --no-upload", + "test:hotpaths": "lune run tests/hotpaths/run.luau" + }, "dependencies": { "moonwave": "^1.2.0", "opencode-ai": "^1.18.3" diff --git a/perfbench.project.json b/perfbench.project.json new file mode 100644 index 00000000..26e95d22 --- /dev/null +++ b/perfbench.project.json @@ -0,0 +1,16 @@ +{ + "name": "FastCast2PerfBench", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "FastCast2": { + "$path": "src" + } + }, + "ServerScriptService": { + "PerfBench": { + "$path": "perfbench/PerfBench.server.luau" + } + } + } +} diff --git a/perfbench/Entry.luau b/perfbench/Entry.luau new file mode 100644 index 00000000..4f5e8961 --- /dev/null +++ b/perfbench/Entry.luau @@ -0,0 +1,20 @@ +--[[ + run-in-roblox entry for the FastCast2 perf benchmark. + + Starts Run mode so the PerfBench server script executes, then waits until + it signals completion. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +if not RunService:IsRunning() then + RunService:Run() +end + +local deadline = os.clock() + 300 +while not ReplicatedStorage:GetAttribute("PerfBenchDone") and os.clock() < deadline do + task.wait(0.5) +end + +print("PERFBENCH_ENTRY_DONE") diff --git a/perfbench/PerfBench.server.luau b/perfbench/PerfBench.server.luau new file mode 100644 index 00000000..19c277f3 --- /dev/null +++ b/perfbench/PerfBench.server.luau @@ -0,0 +1,181 @@ +--[[ + FastCast2 A/B performance benchmark. + + Runs in Studio via run-in-roblox. Fires N raycasts, samples server + Heartbeat frame times, and prints one PERFBENCH_RESULT line per case so the + orchestrator can diff fixed-step (BindToSimulation) vs per-frame stepping. + + The simulation mode is baked into src/Config.luau at build time; this + script only reports whatever `Config.SimulationMode` currently is. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local ServerScriptService = game:GetService("ServerScriptService") + +local FastCast = require(ReplicatedStorage:WaitForChild("FastCast2")) +local FastCastConfig = require(ReplicatedStorage.FastCast2:WaitForChild("Config")) + +local SENTINEL = "PERFBENCH_RESULT" +local SAMPLE_SECONDS = 5 +local WORKERS = 4 +local COUNTS = { 6000 } +local CAST_MODES = { "Parallel", "Serial" } +local INSTANCED = { true } +local MOVEMENT = { true, false } + +local cosmeticContainer = Instance.new("Folder") +cosmeticContainer.Name = "PerfBenchCosmetics" +cosmeticContainer.Parent = workspace + +local cosmeticTemplate = Instance.new("Part") +cosmeticTemplate.Name = "PerfBenchProjectile" +cosmeticTemplate.Size = Vector3.one +cosmeticTemplate.Anchored = true +cosmeticTemplate.CanCollide = false +cosmeticTemplate.CanQuery = false +cosmeticTemplate.CanTouch = false +cosmeticTemplate.Massless = true +cosmeticTemplate.Parent = ReplicatedStorage + +local frameTimes: { number } = {} +local measuring = false + +RunService.Heartbeat:Connect(function(deltaTime) + if measuring and deltaTime > 0 then + table.insert(frameTimes, deltaTime * 1000) + end +end) + +local function summarize(): (number, number, number, number) + if #frameTimes == 0 then + return 0, 0, 0, 0 + end + + local sorted = table.clone(frameTimes) + table.sort(sorted) + + local total = 0 + for _, value in frameTimes do + total += value + end + + local average = total / #frameTimes + local function percentile(fraction: number): number + return sorted[math.max(1, math.ceil(#sorted * fraction))] + end + + return average, percentile(0.50), percentile(0.95), percentile(0.99) +end + +local behavior = FastCast.newBehavior() +behavior.MaxDistance = 1e9 +behavior.Acceleration = Vector3.zero +behavior.AutoIgnoreContainer = true +behavior.HighFidelitySegmentSize = 1 + +local caseIndex = 0 + +local function runCase(castMode: string, projectileCount: number, instanced: boolean, movement: boolean) + caseIndex += 1 + + behavior.CosmeticBulletTemplate = if instanced then cosmeticTemplate else nil + behavior.CosmeticBulletContainer = if instanced then cosmeticContainer else nil + + local caster + if castMode == "Parallel" then + caster = FastCast.newParallel() + caster:Init( + WORKERS, + "BulkMoveTo" + ) + else + caster = FastCast.new() + caster:Init("BulkMoveTo") + end + + caster:SetMovementModeEnabled(movement, "BulkMoveTo") + + local random = Random.new(2026) + local creationStarted = os.clock() + + for _ = 1, projectileCount do + local origin = Vector3.new( + random:NextInteger(-100, 100), + random:NextInteger(0, 100), + random:NextInteger(-100, 100) + ) + local direction = Vector3.new( + random:NextNumber(-1, 1), + random:NextNumber(-1, 1), + random:NextNumber(-1, 1) + ) + direction = if direction.Magnitude == 0 then Vector3.xAxis else direction.Unit + + caster:RaycastFire(origin, direction, 6, behavior) + end + + local creationMs = (os.clock() - creationStarted) * 1000 + + -- Let parallel workers create their casts before sampling. + task.wait() + table.clear(frameTimes) + measuring = true + task.wait(SAMPLE_SECONDS) + measuring = false + + local average, p50, p95, p99 = summarize() + + local cleanupStarted = os.clock() + caster:Destroy() + local cleanupMs = (os.clock() - cleanupStarted) * 1000 + + print(string.format( + "%s %s|%s|%d|instanced=%s|movement=%s|create=%.1f|avg=%.2f|p50=%.2f|p95=%.2f|p99=%.2f|cleanup=%.1f", + SENTINEL, + FastCastConfig.SimulationMode, + castMode, + projectileCount, + tostring(instanced), + tostring(movement), + creationMs, + average, + p50, + p95, + p99, + cleanupMs + )) + + task.wait(2) +end + +task.spawn(function() + task.wait(2) + print(string.format("[perfbench] mode=%s", FastCastConfig.SimulationMode)) + + for _, castMode in CAST_MODES do + for _, projectileCount in COUNTS do + for _, instanced in INSTANCED do + for _, movement in MOVEMENT do + local ok, err = pcall(runCase, castMode, projectileCount, instanced, movement) + if not ok then + print(string.format( + "%s %s|%s|%d|instanced=%s|movement=%s|ERROR|%s", + SENTINEL, + FastCastConfig.SimulationMode, + castMode, + projectileCount, + tostring(instanced), + tostring(movement), + tostring(err) + )) + end + end + end + end + end + + print("[perfbench] done") + ReplicatedStorage:SetAttribute("PerfBenchDone", true) + print(SENTINEL .. " DONE") +end) diff --git a/rokit.toml b/rokit.toml index f9a0f232..c2dcc7f1 100644 --- a/rokit.toml +++ b/rokit.toml @@ -4,7 +4,9 @@ # New tools can be added by running `rokit add ` in a terminal. [tools] -rojo = "rojo-rbx/rojo@7.6.1" +rojo = "rojo-rbx/rojo@7.7.0" wally = "UpliftGames/wally@0.3.2" luau-lsp = "JohnnyMorganz/luau-lsp@1.68.0" selene = "Kampfkarren/selene@0.31.0" +lune = "lune-org/lune@0.10.1" +run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" diff --git a/selene.toml b/selene.toml index 1f1e1703..e51033da 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,2 @@ -std = "roblox" \ No newline at end of file +std = "roblox" +exclude = ["tests/run.luau", "tests/hotpaths/run.luau"] diff --git a/src/ActiveCast.luau b/src/ActiveCast.luau index 21bded75..20e6594f 100644 --- a/src/ActiveCast.luau +++ b/src/ActiveCast.luau @@ -1,9 +1,6 @@ --[[ - Author : Mawin CK - Date : 2025 - - - ActiveCastSerial - Serial mode with single RunService, SoA pattern, queue technique ]] local FastCastModule = script.Parent @@ -49,7 +46,6 @@ function ActiveCast.createCastData( behavior: TypeDef.FastCastBehavior, eventModule: TypeDef.FastCastEventsModule?, variant: CastVariants, - ObjectCacheRef: any, _parallel: boolean? ): any local cast = { @@ -90,10 +86,12 @@ function ActiveCast.createCastData( }, _Destroying = false, + _TerminationQueued = false, Type = variant.CastType, CastVariant = variant, CFrame = CFrame.new(origin), - ID = activeCastID + ID = activeCastID, + Mode = if _parallel then "Parallel" else "Serial", } if _parallel then @@ -117,22 +115,20 @@ function ActiveCast.createCastData( end local targetContainer: Instance? - if ObjectCacheRef then - cast.RayInfo.CosmeticBulletObject = ObjectCacheRef:GetPart(if direction ~= Vector3.new() then CFrame.new(origin, origin + direction) else CFrame.new(origin)) - targetContainer = cast.Caster.CacheHolder - else - if cast.RayInfo.CosmeticBulletObject ~= nil then - local basePart = cast.RayInfo.CosmeticBulletObject + + if cast.RayInfo.CosmeticBulletObject ~= nil then + local basePart = cast.RayInfo.CosmeticBulletObject + if behavior.CloneTemplate == true then basePart = basePart:Clone() - basePart.CFrame = if direction ~= Vector3.new() then CFrame.new(origin, origin + direction) else CFrame.new(origin) - basePart.Parent = behavior.CosmeticBulletContainer - - cast.RayInfo.CosmeticBulletObject = basePart end + basePart.CFrame = if direction ~= Vector3.new() then CFrame.new(origin, origin + direction) else CFrame.new(origin) + basePart.Parent = behavior.CosmeticBulletContainer - if behavior.CosmeticBulletContainer then - targetContainer = behavior.CosmeticBulletContainer - end + cast.RayInfo.CosmeticBulletObject = basePart + end + + if behavior.CosmeticBulletContainer then + targetContainer = behavior.CosmeticBulletContainer end if behavior.AutoIgnoreContainer == true and targetContainer ~= nil then diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index f8dff981..26532c5c 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -10,7 +10,6 @@ local FastCastEnums = require(FastCast2:WaitForChild("FastCastEnums")) local ActiveCast = require(FastCast2:WaitForChild("ActiveCast")) local TypeDef = require(FastCast2:WaitForChild("TypeDefinitions")) local ParallelSimulation = require(FastCast2:WaitForChild("ParallelSimulation")) -local ObjectCache = require(FastCast2:WaitForChild("ObjectCache")) local Motor6DCache = require(FastCast2:WaitForChild("Motor6DCache")) local FastCastEventsModule: ModuleScript? = nil @@ -20,13 +19,8 @@ local BaseCast = {} BaseCast.__index = BaseCast BaseCast.__type = "BaseCast" -local DEFAULT_CACHE_SIZE = 500 -local DEFAULT_CACHE_HOLDER = workspace - local Actor = nil local Output = nil -local ActiveCastCleaner: BindableEvent = nil -local ObjectCacheInstance: any = nil local Motor6DCacheInstance: any = nil local NextProjectileID = 0 local SyncChanges: BindableEvent = nil @@ -56,67 +50,35 @@ local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastT if castTerminatingFunction then castTerminatingFunction((cast :: any)) end - - cast.Caster.ActiveCastCleaner:Fire(cast.ID) - - for key, _ in (cast :: any) do - cast[key] = nil - end + + table.clear(cast :: any) end local BaseCastInitialized = false local BaseCastInstance = nil function BaseCast.Init(BindableOutput: BindableEvent, Data: any) - if BaseCastInitialized then - warn("[FastCast2] BaseCast already initialized in this Actor. Skipping.") - return BaseCastInstance - end - BaseCastInitialized = true - + return BaseCastInstance + end + BaseCastInitialized = true + local self = setmetatable({}, BaseCast) Actor = BindableOutput.Parent self.Actives = {} + self.Actor = Actor Output = BindableOutput - local BindableCleaner = Instance.new("BindableEvent") - BindableCleaner.Name = "ActiveCastDestroyer" - BindableCleaner.Parent = Actor - - if Data.useObjectCache then - local objectCacheArgs = Data.objectCacheArgs or {} - if not objectCacheArgs.CacheSize then - objectCacheArgs.CacheSize = DEFAULT_CACHE_SIZE - end - - if not objectCacheArgs.CacheHolder then - objectCacheArgs.CacheHolder = DEFAULT_CACHE_HOLDER - end - - ObjectCacheInstance = ObjectCache.new(objectCacheArgs.Template, objectCacheArgs.CacheSize, objectCacheArgs.CacheHolder) :: any - end + Actor:SetAttribute("FastCast2Ready", true) CurrentMovementMode = Data.movementMode or "BulkMoveTo" if CurrentMovementMode == "Motor6D" then Motor6DCacheInstance = Motor6DCache.new() end - ActiveCastCleaner = BindableCleaner - - ActiveCastCleaner.Event:Connect(function(activeCastID: number) + Actor:BindToMessage("Cleanup", function(activeCastID: number) if self.Actives[activeCastID] then - local cast = self.Actives[activeCastID] - if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if ObjectCacheInstance then - ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) - else - cast.RayInfo.CosmeticBulletObject:Destroy() - cast.RayInfo.CosmeticBulletObject = nil - end - end self.Actives[activeCastID] = nil ParallelSimulation.Unregister(activeCastID) - Actor:SetAttribute("Tasks", Actor:GetAttribute("Tasks") - 1) end end) @@ -127,7 +89,6 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) SyncChangesConnection = SyncChanges.Event:Connect(function(cast: TypeDef.ActiveCastData) local ID = cast.ID local TargetCast = self.Actives[ID] - if TargetCast then for i, v in cast do if i == "StateInfo" and type(v) == "table" and type(TargetCast[i]) == "table" then @@ -148,50 +109,25 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) end) ParallelSimulation.Init(self) - ParallelSimulation.SetMovementModeEnabled(true, CurrentMovementMode) - ParallelSimulation.Start() BaseCastInstance = self return self end ---[=[ - -@method Raycast -@within BaseCast - -@param Origin Vector3 -- The origin of the raycast. -@param Direction Vector3 -- The direction of the raycast. -@param Velocity Vector3 | number -- The velocity of the raycast. -@param Behavior FastCastBehavior -- The behavior data for the raycast. -@param GUID string -- The unique identifier for the raycast. - -Create a raycast. - -]=] -function BaseCast:Raycast( - Origin: Vector3, - Direction: Vector3, - Velocity: Vector3 | number, - Behavior: TypeDef.FastCastBehavior -) - Actor:SetAttribute("Tasks", Actor:GetAttribute("Tasks") + 1) +function BaseCast:Raycast(Origin: Vector3, Direction: Vector3, Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior) NextProjectileID += 1 - local cast = ActiveCast.createCastData({ Output = Output, - ActiveCastCleaner = ActiveCastCleaner, + Actor = Actor, SyncChange = SyncChanges, - CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder + CacheHolder = self.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Raycast - } :: any, ObjectCacheInstance, true) - + } :: any, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire then SendCastFire(cast, Origin, Direction, Velocity, Behavior) end @@ -200,63 +136,37 @@ function BaseCast:Raycast( end end ---[=[ - -@method SetFastCastEventsModule -@within BaseCast - -@param moduleScript ModuleScript -- The FastCastEventsModule to set. +function BaseCast:SetFastCastEventsModule(moduleScript: ModuleScript?) + if moduleScript ~= nil and (typeof(moduleScript) ~= "Instance" or not moduleScript:IsA("ModuleScript")) then + error("FastCastEventsModule must be a ModuleScript") + end -]=] -function BaseCast:SetFastCastEventsModule(moduleScript: ModuleScript) FastCastEventsModule = moduleScript - if moduleScript and typeof(moduleScript) == "Instance" and moduleScript:IsA("ModuleScript") then - CastFireFunc = require(moduleScript) - if CastFireFunc.CastFire then - CastFireFunc = CastFireFunc.CastFire - else - CastFireFunc = nil + CastFireFunc = nil + if moduleScript then + local events = require(moduleScript) + if type(events) ~= "table" then + error("FastCastEventsModule must return a table") + end + if type(events.CastFire) == "function" then + CastFireFunc = events.CastFire end end end ---[=[ - -@method Blockcast -@within BaseCast - -@param Origin Vector3 -- The origin of the blockcast. -@param Size Vector3 -- The size of the blockcast. -@param Direction Vector3 -- The direction of the blockcast. -@param Velocity Vector3 | number -- The velocity of the blockcast. -@param Behavior FastCastBehavior -- The behavior data for the blockcast. - -Create a Blockcast. - -]=] -function BaseCast:Blockcast( - Origin: Vector3, - Size: Vector3, - Direction: Vector3, - Velocity: Vector3 | number, - Behavior: TypeDef.FastCastBehavior -) - Actor:SetAttribute("Tasks", Actor:GetAttribute("Tasks") + 1) +function BaseCast:Blockcast(Origin: Vector3, Size: Vector3, Direction: Vector3, Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior) NextProjectileID += 1 - local cast = ActiveCast.createCastData({ Output = Output, - ActiveCastCleaner = ActiveCastCleaner, + Actor = Actor, SyncChange = SyncChanges, - CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder + CacheHolder = self.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Blockcast, Size = Size - } :: any, ObjectCacheInstance, true) - + } :: any, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire then SendCastFire(cast, Origin, Direction, Velocity, Behavior) end @@ -265,43 +175,19 @@ function BaseCast:Blockcast( end end ---[=[ - -@method Spherecast -@within BaseCast - -@param Origin Vector3 -- The origin of the spherecast. -@param Radius number -- The radius of the spherecast. -@param Direction Vector3 -- The direction of the spherecast. -@param Velocity Vector3 | number -- The velocity of the spherecast. -@param Behavior FastCastBehavior -- The behavior data for the spherecast. - -Create a Spherecast. - -]=] -function BaseCast:Spherecast( - Origin: Vector3, - Radius: number, - Direction: Vector3, - Velocity: Vector3 | number, - Behavior: TypeDef.FastCastBehavior -) - Actor:SetAttribute("Tasks", Actor:GetAttribute("Tasks") + 1) +function BaseCast:Spherecast(Origin: Vector3, Radius: number, Direction: Vector3, Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior) NextProjectileID += 1 - local cast = ActiveCast.createCastData({ Output = Output, - ActiveCastCleaner = ActiveCastCleaner, + Actor = Actor, SyncChange = SyncChanges, - CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder + CacheHolder = self.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Spherecast, Radius = Radius - } :: any, ObjectCacheInstance, true) - + } :: any, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire then SendCastFire(cast, Origin, Direction, Velocity, Behavior) end @@ -310,117 +196,47 @@ function BaseCast:Spherecast( end end ---[=[ - @method SetMovementModeEnabled - @within BaseCast - - @param mode "BulkMoveTo" | "Motor6D" -- The movement mode to set. - @param enabled boolean -- Whether to enable or disable the movement mode. - - Sets the movement mode for the casts. This determines how the cast's position is updated during simulation. - -]=] function BaseCast:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") CurrentMovementMode = mode - if mode == "Motor6D" and enabled then - if not Motor6DCacheInstance then - Motor6DCacheInstance = Motor6DCache.new() - end - else - if Motor6DCacheInstance then - Motor6DCacheInstance:Destroy() - Motor6DCacheInstance = nil - end - end - - ParallelSimulation.SetMovementModeEnabled(enabled, mode) -end - -function BaseCast:BindObjectCache( - enabled: boolean, - Template: BasePart?, - CacheSize: number?, - CacheHolder: Instance? -) - if enabled then - if ObjectCacheInstance then - return - end - - if not Template then - error("Template must be provided when enabling ObjectCache.") - end - - if not CacheSize then - CacheSize = DEFAULT_CACHE_SIZE - end - - if not CacheHolder then - CacheHolder = DEFAULT_CACHE_HOLDER - end - ObjectCacheInstance = ObjectCache.new(Template, CacheSize, CacheHolder) + if not Motor6DCacheInstance then Motor6DCacheInstance = Motor6DCache.new() end else - if ObjectCacheInstance then - ObjectCacheInstance:Destroy() - ObjectCacheInstance = nil - end + if Motor6DCacheInstance then Motor6DCacheInstance:Destroy(); Motor6DCacheInstance = nil end end + ParallelSimulation.SetMovementModeEnabled(enabled, mode) end ---[=[ - -@method Destroy -@within BaseCast - -Destroys the BaseCast instance and cleans up resources. - -]=] function BaseCast:Destroy() - if ParallelSimulation then - ParallelSimulation.Stop() - end - - if ObjectCacheInstance then - ObjectCacheInstance:Destroy() - end - - if Motor6DCacheInstance then - Motor6DCacheInstance:Destroy() - end - + if ParallelSimulation then ParallelSimulation.Stop() end + if Motor6DCacheInstance then Motor6DCacheInstance:Destroy() end FastCastEventsModule = nil - - if SyncChangesConnection then - SyncChangesConnection:Disconnect() - SyncChangesConnection = nil - end - + CastFireFunc = nil + if SyncChangesConnection then SyncChangesConnection:Disconnect(); SyncChangesConnection = nil end + local activeList = {} for _, v in self.Actives do table.insert(activeList, v) end + for _, v in activeList do + local castID = v.ID + TerminateCast(v) + self.Actives[castID] = nil + ParallelSimulation.Unregister(castID) end self.Actives = {} setmetatable(self, nil) end --- Motor6D - function BaseCast:_GetMotor6D(projectilePart: BasePart?) - if Motor6DCacheInstance and projectilePart then - return Motor6DCacheInstance:Connect(projectilePart) - end + if Motor6DCacheInstance and projectilePart then return Motor6DCacheInstance:Connect(projectilePart) end return nil end function BaseCast:_ReturnMotor6D(motor6d: Motor6D?) - if Motor6DCacheInstance then - Motor6DCacheInstance:Disconnect(motor6d) - end + if Motor6DCacheInstance then Motor6DCacheInstance:Disconnect(motor6d) end end return BaseCast diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 431556d7..6701baf9 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -10,124 +10,60 @@ local SerialSimulation = require(script.Parent.SerialSimulation) local FastCastEnums = require(FastCast2:WaitForChild("FastCastEnums")) local ActiveCast = require(FastCast2:WaitForChild("ActiveCast")) local TypeDef = require(FastCast2:WaitForChild("TypeDefinitions")) -local ObjectCache = require(FastCast2:WaitForChild("ObjectCache")) local Motor6DCache = require(FastCast2:WaitForChild("Motor6DCache")) local EnumCastTypes = FastCastEnums.CastType ---[=[ - @class BaseCast - - BaseCast is the underlying class that manages cast simulation, lifecycle, and movement modes. - It is used internally by FastCastSerial and FastCastParallel. -]=] local BaseCast = {} -BaseCast.__index = BaseCast -BaseCast.__type = "BaseCast" - -local DEFAULT_CACHE_SIZE = 500 -local DEFAULT_CACHE_HOLDER = workspace local NextProjectileID = 0 +local Actives = {} +local CastFireEvent = nil +local Motor6DCacheInstance = nil +local CurrentMovementMode = "BulkMoveTo" -local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastTerminatingFunction?) - if cast._Destroying then return end - cast._Destroying = true - - if castTerminatingFunction then - castTerminatingFunction((cast :: any)) - end - - for key, _ in (cast :: any) do - cast[key] = nil - end -end - - -function BaseCast.Init(events: TypeDef.FastCastEvents, Data: any) - local self = setmetatable({}, BaseCast) - self.Actives = {} - self.CastFirefn = events.CastFire - self.Motor6DCacheInstance = nil - self.ObjectCacheInstance = nil - self.CurrentMovementMode = "BulkMoveTo" +function BaseCast.Init(events: any, Data: any) + BaseCast.Destroy() - if Data.useObjectCache then - local objectCacheArgs = Data.objectCacheArgs or {} - if not objectCacheArgs.CacheSize then - objectCacheArgs.CacheSize = DEFAULT_CACHE_SIZE - end + CastFireEvent = events.CastFire + Motor6DCacheInstance = nil + CurrentMovementMode = "BulkMoveTo" - if not objectCacheArgs.CacheHolder then - objectCacheArgs.CacheHolder = DEFAULT_CACHE_HOLDER - end + Data = Data or {} - self.ObjectCacheInstance = ObjectCache.new(objectCacheArgs.Template, objectCacheArgs.CacheSize, objectCacheArgs.CacheHolder) :: any + CurrentMovementMode = Data.movementMode or "BulkMoveTo" + if CurrentMovementMode == "Motor6D" then + Motor6DCacheInstance = Motor6DCache.new() end - self.CurrentMovementMode = Data.movementMode or "BulkMoveTo" - if self.CurrentMovementMode == "Motor6D" then - self.Motor6DCacheInstance = Motor6DCache.new() - end - - self.SerialSimulation = SerialSimulation.new() - self.SerialSimulation:Init(self, events) - - self.SerialSimulation:SetMovementModeEnabled(true, self.CurrentMovementMode) - - self.SerialSimulation:Start() - - return self + SerialSimulation.Init(BaseCast, Actives, events) + SerialSimulation.SetMovementModeEnabled(true, CurrentMovementMode) + SerialSimulation.Start() end ---[=[ - -@method Raycast -@within BaseCast - -@param Origin Vector3 -- The origin of the raycast. -@param Direction Vector3 -- The direction of the raycast. -@param Velocity Vector3 | number -- The velocity of the raycast. -@param Behavior FastCastBehavior -- The behavior data for the raycast. -@param GUID string -- The unique identifier for the raycast. - -Create a raycast. +local function FireCastFire(cast, origin, direction, velocity, behavior) + if behavior.FastCastEventsConfig.UseCastFire and CastFireEvent then + CastFireEvent:Fire(cast, origin, direction, velocity, behavior) + end +end -]=] -function BaseCast:Raycast( +function BaseCast.Raycast( Origin: Vector3, Direction: Vector3, Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior ) NextProjectileID += 1 - local cast = ActiveCast.createCastData(self, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { + local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Raycast - } :: any, self.ObjectCacheInstance) + } :: any) - self.SerialSimulation:Register(cast) - self.Actives[cast.ID] = cast - - if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then - self.CastFirefn(cast, Origin, Direction, Velocity, Behavior) - end + SerialSimulation.Register(cast) + Actives[cast.ID] = cast + FireCastFire(cast, Origin, Direction, Velocity, Behavior) end ---[=[ - -@method Blockcast -@within BaseCast - -@param Origin Vector3 -- The origin of the raycast. -@param Size Vector3 -- The size of the raycast. -@param Direction Vector3 -- The direction of the raycast. -@param Velocity Vector3 | number -- The velocity of the raycast. -@param Behavior FastCastBehavior -- The behavior data for the raycast. - -Create a Blockcast. - -]=] -function BaseCast:Blockcast( +function BaseCast.Blockcast( Origin: Vector3, Size: Vector3, Direction: Vector3, @@ -135,35 +71,17 @@ function BaseCast:Blockcast( Behavior: TypeDef.FastCastBehavior ) NextProjectileID += 1 - - local cast = ActiveCast.createCastData(self, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { + local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Blockcast, Size = Size - } :: any, self.ObjectCacheInstance) - - self.SerialSimulation:Register(cast) - self.Actives[cast.ID] = cast + } :: any) - if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then - self.CastFirefn(cast, Origin, Direction, Velocity, Behavior) - end + SerialSimulation.Register(cast) + Actives[cast.ID] = cast + FireCastFire(cast, Origin, Direction, Velocity, Behavior) end ---[=[ - -@method Spherecast -@within BaseCast - -@param Origin Vector3 -- The origin of the spherecast. -@param Radius number -- The radius of the spherecast. -@param Direction Vector3 -- The direction of the spherecast. -@param Velocity Vector3 | number -- The velocity of the spherecast. -@param Behavior FastCastBehavior -- The behavior data for the spherecast. - -Create a Spherecast. - -]=] -function BaseCast:Spherecast( +function BaseCast.Spherecast( Origin: Vector3, Radius: number, Direction: Vector3, @@ -171,156 +89,78 @@ function BaseCast:Spherecast( Behavior: TypeDef.FastCastBehavior ) NextProjectileID += 1 - - local cast = ActiveCast.createCastData(self, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { + local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Spherecast, Radius = Radius - } :: any, self.ObjectCacheInstance) - - self.SerialSimulation:Register(cast) - self.Actives[cast.ID] = cast + } :: any) - if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then - self.CastFirefn(cast, Origin, Direction, Velocity, Behavior) - end + SerialSimulation.Register(cast) + Actives[cast.ID] = cast + FireCastFire(cast, Origin, Direction, Velocity, Behavior) end ---[=[ - -@method SetMovementModeEnabled -@within BaseCast - - @param mode "BulkMoveTo" | "Motor6D" -- The movement mode to set. - @param enabled boolean -- Whether to enable or disable the movement mode. - - Sets the movement mode for the casts. This determines how the cast's position is updated during simulation. - -]=] -function BaseCast:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") - self.CurrentMovementMode = mode +function BaseCast.SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") + CurrentMovementMode = mode if mode == "Motor6D" and enabled then - if not self.Motor6DCacheInstance then - self.Motor6DCacheInstance = Motor6DCache.new() - end - else - if self.Motor6DCacheInstance then - self.Motor6DCacheInstance:Destroy() - self.Motor6DCacheInstance = nil - end - end - - self.SerialSimulation:SetMovementModeEnabled(enabled, mode) -end - -function BaseCast:BindObjectCache( - enabled: boolean, - Template: BasePart?, - CacheSize: number?, - CacheHolder: Instance? -) - if enabled then - if self.ObjectCacheInstance then - return - end - - if not Template then - error("Template must be provided when enabling ObjectCache.") - end - - if not CacheSize then - CacheSize = DEFAULT_CACHE_SIZE - end - - if not CacheHolder then - CacheHolder = DEFAULT_CACHE_HOLDER + if not Motor6DCacheInstance then + Motor6DCacheInstance = Motor6DCache.new() end - self.ObjectCacheInstance = ObjectCache.new(Template, CacheSize, CacheHolder) else - if self.ObjectCacheInstance then - self.ObjectCacheInstance:Destroy() - self.ObjectCacheInstance = nil + if Motor6DCacheInstance then + Motor6DCacheInstance:Destroy() + Motor6DCacheInstance = nil end end -end - ---[=[ - -@method Destroy -@within BaseCast -Destroys the BaseCast instance and cleans up resources. + SerialSimulation.SetMovementModeEnabled(enabled, mode) +end -]=] -function BaseCast:Destroy() - if self.SerialSimulation then - self.SerialSimulation:Stop() +function BaseCast.Destroy() + if SerialSimulation then + SerialSimulation.Stop() end - if self.ObjectCacheInstance then - self.ObjectCacheInstance:Destroy() + local castIDs = {} + for castID in Actives do + table.insert(castIDs, castID) end - - if self.Motor6DCacheInstance then - self.Motor6DCacheInstance:Destroy() + for _, castID in castIDs do + BaseCast._TerminateCast(castID) end + Actives = {} - for _, v in self.Actives do - TerminateCast(v) + if Motor6DCacheInstance then + Motor6DCacheInstance:Destroy() + Motor6DCacheInstance = nil end - self.Actives = {} - setmetatable(self, nil) + CastFireEvent = nil end --- Utils - -function BaseCast:_TerminateCast(castID: number, castTerminatingfn: (...any) -> ()) - if self.Actives[castID] then - local cast = self.Actives[castID] - - if not cast._Destroying then - cast._Destroying = true - if castTerminatingfn then - castTerminatingfn(cast) - end - end +function BaseCast._TerminateCast(castID: number) + local cast = Actives[castID] + if not cast then + return + end - if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if self.ObjectCacheInstance then - self.ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) - else - cast.RayInfo.CosmeticBulletObject:Destroy() - cast.RayInfo.CosmeticBulletObject = nil - end - end + table.clear(cast :: any) + Actives[castID] = nil - for key, _ in (cast :: any) do - cast[key] = nil - end - self.Actives[castID] = nil - end + SerialSimulation.Unregister(castID) end -function BaseCast:_GetMotor6D(projectilePart: BasePart?) - if self.Motor6DCacheInstance and projectilePart then - return self.Motor6DCacheInstance:Connect(projectilePart) +function BaseCast._GetMotor6D(projectilePart: BasePart?) + if Motor6DCacheInstance and projectilePart then + return Motor6DCacheInstance:Connect(projectilePart) end return nil end -function BaseCast:_ReturnMotor6D(motor6d: Motor6D?) - if self.Motor6DCacheInstance then - self.Motor6DCacheInstance:Disconnect(motor6d) - end -end - -function BaseCast:_UpdateEvents(eventName: string, newEventfn: (...any) -> ()) - if eventName == "CastFire" then - self.CastFirefn = newEventfn - return +function BaseCast._ReturnMotor6D(motor6d: Motor6D?) + if Motor6DCacheInstance then + Motor6DCacheInstance:Disconnect(motor6d) end - self.SerialSimulation:_UpdateEvents(eventName, newEventfn) end return BaseCast diff --git a/src/Config.luau b/src/Config.luau index 35524a08..1c88cbe6 100644 --- a/src/Config.luau +++ b/src/Config.luau @@ -14,4 +14,35 @@ Configs.DebugLogging = { } Configs.VisualizeCasts = true +--[[ + How casts are stepped. + + "PerFrame" - One update per frame (PreSimulation on the client, Heartbeat + on the server). Cheapest, and the delta always matches the + real frame time. Recommended default. + + "Fixed" - RunService:BindToSimulation at FixedSimulationFrequency. The + engine keeps the simulation at that fixed rate, so when the + frame rate drops below the frequency it runs MULTIPLE steps + per frame to catch up. Under heavy load this multiplies the + per-frame cast cost (a death spiral), which is why it is not + the default. Only use it if you need deterministic fixed-step + simulation. +]] +Configs.SimulationMode = "PerFrame" +Configs.FixedSimulationFrequency = Enum.StepFrequency.Hz60 + +--[[ + How often cosmetic bullet parts are moved, in simulation frames. + + 1 = move every frame (smoothest). Higher values push cosmetic CFrames + every Nth frame. Moving thousands of parts with BulkMoveTo (and + replicating their CFrames to clients) is by far the most expensive part of + an instanced cast, so 2 or 3 roughly halves/thirds that cost. Roblox + replicates instance CFrames at roughly 20 Hz anyway, so 3 (~20 Hz at 60 + FPS) is the default; use 1 for very fast projectiles that need every-frame + smoothness. Hit detection is unaffected: it still runs every frame. +]] +Configs.CosmeticUpdateInterval = 3 + return Configs diff --git a/src/DefaultConfigs.luau b/src/DefaultConfigs.luau index 8657bb19..26a1eb4f 100644 --- a/src/DefaultConfigs.luau +++ b/src/DefaultConfigs.luau @@ -63,6 +63,8 @@ Defaults.FastCastBehavior = { UseCastFire = true }, + CloneTemplate = true, + FastCastEventsConfig = { UseLengthChanged = false, UseHit = true, diff --git a/src/FastCastVMs/ClientVM.client.luau b/src/FastCastVMs/ClientVM.client.luau index bfb1473e..20a66670 100644 --- a/src/FastCastVMs/ClientVM.client.luau +++ b/src/FastCastVMs/ClientVM.client.luau @@ -73,17 +73,21 @@ actor:BindToMessage("Spherecast", function( BaseCast:Spherecast(origin, radius, direction, velocity, behavior) end) -actor:BindToMessage("SetMovementModeEnabled", function(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") - BaseCast:SetMovementModeEnabled(enabled, mode) +actor:BindToMessage("FireBatch", function(requests: { any }) + for _, request in requests do + local kind = request[1] + if kind == 1 then + BaseCast:Raycast(request[2], request[3], request[4], request[5]) + elseif kind == 2 then + BaseCast:Blockcast(request[2], request[3], request[4], request[5], request[6]) + elseif kind == 3 then + BaseCast:Spherecast(request[2], request[3], request[4], request[5], request[6]) + end + end end) -actor:BindToMessage("BindObjectCache", function( - enabled: boolean, - Template: BasePart?, - CacheSize: number?, - CacheHolder: Instance? -) - BaseCast:BindObjectCache(enabled, Template, CacheSize, CacheHolder) +actor:BindToMessage("SetMovementModeEnabled", function(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") + BaseCast:SetMovementModeEnabled(enabled, mode) end) -- CleanUp diff --git a/src/FastCastVMs/ServerVM.server.luau b/src/FastCastVMs/ServerVM.server.luau index 1d9c55e9..d3643599 100644 --- a/src/FastCastVMs/ServerVM.server.luau +++ b/src/FastCastVMs/ServerVM.server.luau @@ -81,17 +81,21 @@ actor:BindToMessage("Spherecast", function( BaseCast:Spherecast(origin, radius, direction, velocity, behavior) end) -actor:BindToMessage("SetMovementModeEnabled", function(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") - BaseCast:SetMovementModeEnabled(enabled, mode) +actor:BindToMessage("FireBatch", function(requests: { any }) + for _, request in requests do + local kind = request[1] + if kind == 1 then + BaseCast:Raycast(request[2], request[3], request[4], request[5]) + elseif kind == 2 then + BaseCast:Blockcast(request[2], request[3], request[4], request[5], request[6]) + elseif kind == 3 then + BaseCast:Spherecast(request[2], request[3], request[4], request[5], request[6]) + end + end end) -actor:BindToMessage("BindObjectCache", function( - enabled: boolean, - Template: BasePart?, - CacheSize: number?, - CacheHolder: Instance? -) - BaseCast:BindObjectCache(enabled, Template, CacheSize, CacheHolder) +actor:BindToMessage("SetMovementModeEnabled", function(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") + BaseCast:SetMovementModeEnabled(enabled, mode) end) -- CleanUp diff --git a/src/FastCastVMs/init.luau b/src/FastCastVMs/init.luau index d729b7c8..2af2fed4 100644 --- a/src/FastCastVMs/init.luau +++ b/src/FastCastVMs/init.luau @@ -80,8 +80,7 @@ function Dispatcher.Init(newContainerParent : Instance, VMContainerName : string -- Init - local Actor = Instance.new("Actor") - Actor:SetAttribute("Tasks", 0) + local Actor = Instance.new("Actor") local Controller if IS_SERVER then @@ -158,10 +157,13 @@ function Dispatcher:Allocate(Threads: number, Data: any?, Callback: (...any) -> local Actors = {} --> Create actors - for _ = 1, Threads do + for index = 1, Threads do local Actor = Template:Clone() Actor.Parent = Container + -- Stable per-worker index, used to phase-offset cosmetic updates. + Actor:SetAttribute("FastCastWorkerIndex", #self.Threads + index) + local controller = Actor:FindFirstChild(ControllerName) if Callback then @@ -181,9 +183,29 @@ function Dispatcher:Allocate(Threads: number, Data: any?, Callback: (...any) -> --> Allow actors to start RunService.PostSimulation:Wait() - --> Initialize actors - for _, Actor in Actors do - Actor:SendMessage("Init", Data) + --> Initialize actors. A message is dropped if the controller script has + --> not bound its topics yet, and client controllers can start a few + --> frames late, so retry until every actor reports that it initialized. + for attempt = 1, 30 do + local pending = false + + for _, Actor in Actors do + if Actor:GetAttribute("FastCast2Ready") ~= true then + pending = true + Actor:SendMessage("Init", Data) + end + end + + if not pending then + break + end + + if attempt == 30 then + warn("[FastCastVMs] Some actors did not initialize. RunContext : " + .. (IS_SERVER and "Server" or "Client")) + end + + RunService.PostSimulation:Wait() end --> Merge actors into threads diff --git a/src/ObjectCache.luau b/src/ObjectCache.luau deleted file mode 100644 index 2415e5ff..00000000 --- a/src/ObjectCache.luau +++ /dev/null @@ -1,204 +0,0 @@ ---[[ - - Modded By Mawin_CK - Desc : i added __type = "ObjectCache" to letting FastCast Recongize that this is ObjectCache -]] - ---[=[ - -@class ObjectCache -@private -@external ObjectCache https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112 -ObjectCache usage should be derived from their DevForum post: - -https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112 - -]=] - ---!strict ---!native -local HTTPS = game:GetService("HttpService") - -local FAR_AWAY_CFRAME = CFrame.new(2^24, 2^24, 2^24) -local EXPAND_BY_AMOUNT = 50 - -local MovingParts = table.create(10_000) -local MovingCFrames = table.create(10_000) - -local ScheduledUpdate = false -local function UpdateMovement() - while true do - workspace:BulkMoveTo(MovingParts, MovingCFrames, Enum.BulkMoveMode.FireCFrameChanged) - - table.clear(MovingParts) - table.clear(MovingCFrames) - - ScheduledUpdate = false - coroutine.yield() - end -end -local UpdateMovementThread = coroutine.create(UpdateMovement) - -local Cache = {} -Cache.__index = Cache -Cache.__type = "ObjectCache" - -function Cache:_GetNew(Amount: number, Warn: boolean) - if Warn then - warn(`ObjectCache: Cache retrieval exceeded preallocated amount! expanding by {Amount}...`) - end - - local FreeObjectsContainer = self._FreeObjects - local InitialLength = #self._FreeObjects - local CacheHolder = self.CacheHolder - - local IsTemplateModel = self._IsTemplateModel - local Template: Model | BasePart = self._Template - - local TargetParts = table.create(Amount) - local TargetCFrames = table.create(Amount) - local AddedObjects = table.create(Amount) - for Index = InitialLength + 1, InitialLength + Amount do - local Object = Template:Clone() - local ObjectRoot: BasePart = if IsTemplateModel then (Object:: Model).PrimaryPart:: BasePart else Object:: BasePart - - FreeObjectsContainer[Index] = Object - - local OffsetIndex = Index - InitialLength - TargetParts[OffsetIndex] = ObjectRoot - TargetCFrames[OffsetIndex] = FAR_AWAY_CFRAME - AddedObjects[OffsetIndex] = Object - end - - workspace:BulkMoveTo(TargetParts, TargetCFrames, Enum.BulkMoveMode.FireCFrameChanged) - - for _, Object in AddedObjects do - (Object:: Instance).Parent = CacheHolder - end - - return table.remove(FreeObjectsContainer) -end - -function Cache:GetPart(PartCFrame: CFrame?): BasePart - local Object = table.remove(self._FreeObjects) or self:_GetNew(self._ExpandAmount, true) - local Part = if self._IsTemplateModel then (Object :: Model).PrimaryPart :: BasePart else Object :: BasePart - - self._PartToObject[Part] = Object - self._Objects[Part] = nil - if PartCFrame then - table.insert(MovingParts, Part) - table.insert(MovingCFrames, PartCFrame) - - if not ScheduledUpdate then - ScheduledUpdate = true - task.defer(UpdateMovementThread) - end - end - - return Part -end -function Cache:ReturnPart(Part: BasePart) - if self._Objects[Part] then - return - end - - self._Objects[Part] = true - - local Object = self._PartToObject[Part] - if Object then - self._PartToObject[Part] = nil - else - Object = Part - end - - table.insert(self._FreeObjects, Object) - table.insert(MovingParts, Part) - table.insert(MovingCFrames, FAR_AWAY_CFRAME) - - if not ScheduledUpdate then - ScheduledUpdate = true - task.defer(UpdateMovementThread) - end -end - -function Cache:Update() - task.spawn(UpdateMovementThread) -end - -function Cache:ExpandCache(Amount: number) - assert(typeof(Amount) == "number" and Amount >= 0, `Invalid argument #1 to 'ObjectCache:ExpandCache' (positive number expected, got {typeof(Amount)})`) - self:_GetNew(Amount, false) -end -function Cache:SetExpandAmount(Amount: number) - assert(typeof(Amount) == "number" and Amount > 0, `Invalid argument #1 to 'ObjectCache:SetExpandAmount' (positive number expected, got {typeof(Amount)})`) - self._ExpandAmount = Amount -end - -function Cache:IsInUse(Object: BasePart): boolean - return self._Objects[Object] == nil -end - -function Cache:Destroy() - self.CacheHolder:Destroy() -end - -local function GetCacheContainer() - local CacheHolder = Instance.new("Folder") - CacheHolder.Name = "ObjectCache " .. HTTPS:GenerateGUID(false) - - return CacheHolder -end - -local Constructor = {} -function Constructor.new(Template: BasePart | Model, CacheSize: number?, CachesContainer: Instance?) - local TemplateType = typeof(Template) - assert(TemplateType == "Instance", `Invalid argument #1 to 'ObjectCache.new' (BasePart expected, got {TemplateType})`) - - assert(Template:IsA("BasePart") or Template:IsA("Model"), `Invalid argument #1 to 'ObjectCache.new' (BasePart or Model expected, got {Template.ClassName})`) - assert(Template.Archivable, `ObjectCache: Cannot use template object provided, as it has Archivable set to false.`) - if Template:IsA("Model") then - assert(Template.PrimaryPart ~= nil, `Invalid Template provided to 'ObjectCache.new': Model has no PrimaryPart set!`) - end - - local CacheSizeType = typeof(CacheSize) - assert(CacheSize == nil or CacheSizeType == "number", `Invalid argument #2 to 'ObjectCache.new' (number expected, got {CacheSizeType})`) - assert(CacheSize == nil or CacheSize >= 0, `Invalid argument #2 to 'ObjectCache.new' (positive number expected, got {CacheSize})`) - - local ContainerType = typeof(CachesContainer) - assert(CachesContainer == nil or ContainerType == "Instance", `Invalid argument #3 to 'ObjectCache.new' (Instance expected, got {ContainerType})`) - - local PreallocAmount = CacheSize or 10 - local CacheParent = GetCacheContainer() - - local Objects: {[BasePart]: boolean} = {} - local FreeObjects: {BasePart | Model} = table.create(PreallocAmount) - - local TargetParts = table.create(PreallocAmount) - - local IsTemplateModel = Template:IsA("Model") - for Index = 1, PreallocAmount do - local Object = Template:Clone() - local ObjectRoot: BasePart = if IsTemplateModel then (Object:: Model).PrimaryPart:: BasePart else Object:: BasePart - - FreeObjects[Index] = Object - TargetParts[Index] = ObjectRoot - - ObjectRoot.CFrame = FAR_AWAY_CFRAME; - (Object:: Instance).Parent = CacheParent - end - - CacheParent.Parent = CachesContainer or workspace - - return setmetatable({ - CacheHolder = CacheParent, - _ExpandAmount = EXPAND_BY_AMOUNT, - _Template = Template, - _FreeObjects = FreeObjects, - _Objects = Objects, - _PartToObject = {}, - _IsTemplateModel = IsTemplateModel, - _PreallocatedAmount = PreallocAmount, - Type = "ObjectCache" - }, Cache) -end - -return Constructor diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 50d0827f..403d26e3 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -6,259 +6,323 @@ --!native --!optimize 2 --- Services - local RS = game:GetService("RunService") local FastCastModule = script.Parent --- Requires - local TypeDef = require(FastCastModule:WaitForChild("TypeDefinitions")) local FastCastEnums = require(FastCastModule:WaitForChild("FastCastEnums")) -local Config = require(script.Parent:WaitForChild("Config")) -local DebugLogging = Config.DebugLogging - +local Config = require(FastCastModule:WaitForChild("Config")) --- Constants +local DebugLogging = Config.DebugLogging local EnumCastTypes = FastCastEnums.CastType + local DEFAULT_MAX_DISTANCE = 1000 local FC_VIS_OBJ_NAME = "FastCastVisualizationObjects" --- Variables +-- ============================================================================ +-- Types +-- ============================================================================ + +type BlockcastVariant = { + CastType: number, + Size: Vector3 +} + +type SpherecastVariant = { + CastType: number, + Radius: number +} + +type CastVariants = BlockcastVariant | SpherecastVariant + +type RayVisualizerVariant = { + castLength: number +} + +type BlockVisualizerVariant = { + size: Vector3 +} + +type SphereVisualizerVariant = { + radius: number +} + +type CastVisualizerVariants = + RayVisualizerVariant + | BlockVisualizerVariant + | SphereVisualizerVariant + +-- Event type constants (numeric IDs avoid string comparison in hot path) +local EVT_LENGTH_CHANGED = 1 +local EVT_HIT = 2 +local EVT_PIERCED = 3 +local EVT_CAST_TERMINATING = 4 + +--[[ + Shoutout to DrSinek for some code +]] +local EPSILON = 1e-6 +local RAY_SEARCH_OFFSET = 1e-4 + +type QueuedVisualizeCastData = { + castStartCFrame: CFrame, + sub: boolean +} + +type QueuedVisualizeHitData = { + atCF: CFrame, + wasPierce: boolean, + sub: boolean +} + +type QueuedVis = { any } + +-- ============================================================================ +-- State +-- ============================================================================ local casts_TotalRunTime = {} :: { [number]: number } local casts_DistanceCovered = {} :: { [number]: number } + local casts_HighFidelitySegmentSize = {} :: { [number]: number } local casts_HighFidelityBehavior = {} :: { [number]: number } + local casts_IsActivelySimulatingPierce = {} :: { [number]: boolean } local casts_IsActivelyResimulating = {} :: { [number]: boolean } local casts_CancelHighResCast = {} :: { [number]: boolean } + local casts_Trajectory = {} :: { [number]: TypeDef.CastTrajectory } -local casts_FastCastEventsModuleConfig = {} :: { [number]: TypeDef.FastCastEventsModuleConfig } -local casts_FastCastEventsConfig = {} :: { [number]: TypeDef.FastCastEventsConfig } + +local casts_FastCastEventsModuleConfig = + {} :: { [number]: TypeDef.FastCastEventsModuleConfig } + +local casts_FastCastEventsConfig = + {} :: { [number]: TypeDef.FastCastEventsConfig } + local casts_VisualizeCasts = {} :: { [number]: boolean } -local casts_VisualizeCastSettings = {} :: { [number]: TypeDef.VisualizeCastSettings } +local casts_VisualizeCastSettings = + {} :: { [number]: TypeDef.VisualizeCastSettings } + local casts_RayInfo = {} :: { [number]: TypeDef.CastRayInfo } local casts_UserData = {} :: { [number]: any } + local casts_CFrame = {} :: { [number]: CFrame } + local casts_CastType = {} :: { [number]: number } local casts_CastVariant = {} :: { [number]: CastVariants } + local casts_MaxDistance = {} :: { [number]: number } -local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } local casts_RayDisplacement = {} :: { [number]: number } +local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } + local casts_ID = {} :: { number } local casts_ID_Index = {} :: { [number]: number } local casts_FastCastEvents = {} :: { [number]: TypeDef.FastCastEvents } --- Types - -type QueuedEventData = { - eventType: string, - args: { any } -} - -type QueuedVisualizeCastData = { - castStartCFrame: CFrame, - sub: boolean -} +-- Cached per-cast "does anyone care about this event?" flags. +-- Precomputed at registration so the hot path avoids config lookups and, +-- more importantly, avoids queueing events nobody listens to. +local casts_WantLengthChanged = {} :: { [number]: boolean } +local casts_HasCosmetic = {} :: { [number]: boolean } +local castsWithCosmetics: number = 0 + +-- Packed metadata: f64 cast ID + u8 event type. Keep IDs lossless (no u32 +-- wraparound); Roblox values stay in the reusable argument tables below. +-- Capacity doubles only at a new high-water mark, never during steady state. +local EVENT_STRIDE = 9 +local eventCapacity = 256 +local eventMetadata = buffer.create(eventCapacity * EVENT_STRIDE) +local queuedEventsCount: number = 0 + +-- Flat visualization queue: { castID, data } +-- Reused every frame via table.clear +local queuedVisualizes: { QueuedVis } = {} +local queuedVisCount: number = 0 -type QueuedVisualizeHitData = { - atCF: CFrame, - wasPierce: boolean, - sub: boolean -} +local ActivesRef: any = nil +local BaseCastRef: any = nil -type BlockcastVariant = { CastType: number, Size: Vector3 } -type SpherecastVariant = { CastType: number, Radius: number } -type CastVariants = BlockcastVariant | SpherecastVariant +local CurrentMovementMode: "BulkMoveTo" | "Motor6D" = "BulkMoveTo" +local MovementEnabled = false -type RayVisualizerVariant = { castLength: number } -type BlockVisualizerVariant = { size: Vector3 } -type SphereVisualizerVariant = { radius: number } -type CastVisualizerVariants = RayVisualizerVariant | BlockVisualizerVariant | SphereVisualizerVariant +-- ============================================================================ +-- Math +-- ============================================================================ --- queued things +local function GetPositionAtTime( + t: number, + origin: Vector3, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + return origin + + initialVelocity * t + + acceleration * (t * t * 0.5) +end -local queuedEvents: { [number]: { QueuedEventData } } = {} -local queuedVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData } } = {} +local function GetVelocityAtTime( + t: number, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + return initialVelocity + acceleration * t +end -local ActivesRef: any = nil -local BaseCastRef = nil :: any -local CurrentMovementMode: "BulkMoveTo" | "Motor6D" = "BulkMoveTo" -local MovementEnabled = false +-- ============================================================================ +-- Cast handlers +-- ============================================================================ local castHandlers = { [EnumCastTypes.Raycast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams ) - return targetWorldRoot:Raycast(origin, direction, params) + return worldRoot:Raycast(origin, direction, params) end, + [EnumCastTypes.Blockcast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams, variant: BlockcastVariant ) - return targetWorldRoot:Blockcast(CFrame.new(origin), variant.Size, direction, params) + return worldRoot:Blockcast( + CFrame.new(origin), + variant.Size, + direction, + params + ) end, + [EnumCastTypes.Spherecast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams, variant: SpherecastVariant ) - return targetWorldRoot:Spherecast(origin, variant.Radius, direction, params) + return worldRoot:Spherecast( + origin, + variant.Radius, + direction, + params + ) end } --- Utils - -local function GetPositionAtTime( - t: number, - origin: Vector3, - initialVelocity: Vector3, - acceleration: Vector3 -): Vector3 - local force = Vector3.new( - (acceleration.X * t ^ 2) / 2, - (acceleration.Y * t ^ 2) / 2, - (acceleration.Z * t ^ 2) / 2 - ) - return origin + (initialVelocity * t) + force -end - -local function GetVelocityAtTime(time: number, initialVelocity: Vector3, acceleration: Vector3): Vector3 - return initialVelocity + acceleration * time -end - -local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastTerminatingFunction?) - if cast._Destroying then return end - cast._Destroying = true - - local FastCastEventsConfig = cast.StateInfo.FastCastEventsConfig - if FastCastEventsConfig and FastCastEventsConfig.UseCastTerminating then - cast.Caster.Output:Fire("CastTerminating", cast) - end - - if castTerminatingFunction then - castTerminatingFunction((cast :: any)) - end - - cast.Caster.ActiveCastCleaner:Fire(cast.ID) -end +-- ============================================================================ +-- Visualization +-- ============================================================================ -local function DebrisAdd(obj: Instance, Lifetime: number) - if not obj then - return - end - if Lifetime <= 0 then +local function DebrisAdd(obj: Instance, lifetime: number) + if lifetime <= 0 then obj:Destroy() return end - task.delay(Lifetime, function() - obj:Destroy() + task.delay(lifetime, function() + if obj.Parent then + obj:Destroy() + end end) end -local function GetFastCastVisualizationContainer(): Instance - local fcVisualizationObjects = workspace.Terrain:FindFirstChild(FC_VIS_OBJ_NAME) - if fcVisualizationObjects then - return fcVisualizationObjects +local function GetVisualizationContainer(): Instance + local container = workspace.Terrain:FindFirstChild(FC_VIS_OBJ_NAME) + + if container then + return container end - fcVisualizationObjects = Instance.new("Folder") - fcVisualizationObjects.Name = FC_VIS_OBJ_NAME - fcVisualizationObjects.Archivable = false - fcVisualizationObjects.Parent = workspace.Terrain - return fcVisualizationObjects + container = Instance.new("Folder") + container.Name = FC_VIS_OBJ_NAME + container.Archivable = false + container.Parent = workspace.Terrain + + return container end local function DbgVisualizeRaySegment( - castStartCFrame: CFrame, - VisualizeCastSettings: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: RayVisualizerVariant -): ConeHandleAdornment? +) local adornment = Instance.new("ConeHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame + adornment.CFrame = startCFrame adornment.Height = variant.castLength - adornment.Color3 = VisualizeCastSettings.Debug_SegmentColor - adornment.Radius = VisualizeCastSettings.Debug_SegmentSize - adornment.Transparency = VisualizeCastSettings.Debug_SegmentTransparency - adornment.Parent = GetFastCastVisualizationContainer() + adornment.Color3 = settings.Debug_SegmentColor + adornment.Radius = settings.Debug_SegmentSize + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - DebrisAdd(adornment, VisualizeCastSettings.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeBlockSegment( - castStartCFrame: CFrame, - VisualizeCastSetting: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: BlockVisualizerVariant -): BoxHandleAdornment? +) local adornment = Instance.new("BoxHandleAdornment") - adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame - --adornment.Height = castLength + adornment.Adornee = workspace.Terrain + adornment.CFrame = startCFrame adornment.Size = variant.size - adornment.Color3 = VisualizeCastSetting.Debug_SegmentColor - adornment.Transparency = VisualizeCastSetting.Debug_SegmentTransparency - - adornment.Parent = GetFastCastVisualizationContainer() + adornment.Color3 = settings.Debug_SegmentColor + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - DebrisAdd(adornment, VisualizeCastSetting.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeSphereSegment( - castStartCFrame: CFrame, - VisualizeCastSetting: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: SphereVisualizerVariant -): SphereHandleAdornment? +) local adornment = Instance.new("SphereHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame - --adornment.Height = castLength + adornment.CFrame = startCFrame adornment.Radius = variant.radius - --adornment.Size = Vector3.new(size.X, size.Y, size.Z + castLength) - adornment.Color3 = VisualizeCastSetting.Debug_SegmentColor - adornment.Transparency = VisualizeCastSetting.Debug_SegmentTransparency - - adornment.Parent = GetFastCastVisualizationContainer() + adornment.Color3 = settings.Debug_SegmentColor + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - DebrisAdd(adornment, VisualizeCastSetting.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeHit( - atCF: CFrame, + atCFrame: CFrame, wasPierce: boolean, - VisualizeCastSettings: TypeDef.VisualizeCastSettings -): SphereHandleAdornment? + settings: TypeDef.VisualizeCastSettings +) local adornment = Instance.new("SphereHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = atCF - -- Alert! someone is Mawining it!!!!! - adornment.Radius = (wasPierce == false) and VisualizeCastSettings.Debug_HitSize - or VisualizeCastSettings.Debug_RayPierceSize - adornment.Transparency = (wasPierce == false) and VisualizeCastSettings.Debug_HitTransparency - or VisualizeCastSettings.Debug_RayPierceTransparency - adornment.Color3 = (wasPierce == false) and VisualizeCastSettings.Debug_HitColor - or VisualizeCastSettings.Debug_RayPierceColor - adornment.Parent = GetFastCastVisualizationContainer() + adornment.CFrame = atCFrame + + if wasPierce then + adornment.Radius = settings.Debug_RayPierceSize + adornment.Transparency = settings.Debug_RayPierceTransparency + adornment.Color3 = settings.Debug_RayPierceColor + else + adornment.Radius = settings.Debug_HitSize + adornment.Transparency = settings.Debug_HitTransparency + adornment.Color3 = settings.Debug_HitColor + end - DebrisAdd(adornment, VisualizeCastSettings.Debug_HitLifetime) - return adornment + adornment.Parent = GetVisualizationContainer() + + DebrisAdd(adornment, settings.Debug_HitLifetime) end local Visualizers = { @@ -267,174 +331,461 @@ local Visualizers = { [EnumCastTypes.Spherecast] = DbgVisualizeSphereSegment } -local function QueueVisualize(castID: number, visualizeData: QueuedVisualizeCastData | QueuedVisualizeHitData) +-- ============================================================================ +-- Pre-allocated pools (reused every frame, never freed) +-- ============================================================================ + +local argsPool: { { any } } = {} +local argsPoolCount: number = 0 + +local function GetArgs(): { any } + argsPoolCount += 1 + if argsPoolCount > #argsPool then + argsPool[argsPoolCount] = {} + end + return argsPool[argsPoolCount] +end + +-- Pooled records for the worker -> main event batch. +-- Layout: { eventType, cast, args, argCount } +local batchPool: { { any } } = {} +local batchPoolCount: number = 0 + +local function GetBatchRecord(): { any } + batchPoolCount += 1 + if batchPoolCount > #batchPool then + batchPool[batchPoolCount] = { 0, false, false, 0 } + end + return batchPool[batchPoolCount] +end +local visPool: { QueuedVis } = {} +local visPoolCount: number = 0 + +local function GetVis(): QueuedVis + visPoolCount += 1 + if visPoolCount > #visPool then + visPool[visPoolCount] = { 0, nil } + end + return visPool[visPoolCount] +end + +-- Pooled visualization data tables (avoids per-frame allocations) +-- Hit/pierce data layout: { atCF :: CFrame, wasPierce :: boolean, sub :: boolean } +-- Segment data layout: { castStartCFrame :: CFrame, sub :: boolean } +local hitVisPool: { { any } } = {} +local hitVisPoolCount: number = 0 + +local segVisPool: { { any } } = {} +local segVisPoolCount: number = 0 + +local function GetHitVisData(): { any } + hitVisPoolCount += 1 + if hitVisPoolCount > #hitVisPool then + -- NOTE: Do not change ":: any" I just hate annoying warning + hitVisPool[hitVisPoolCount] = { nil :: any, false, false } + end + return hitVisPool[hitVisPoolCount] +end + +local function GetSegVisData(): { any } + segVisPoolCount += 1 + if segVisPoolCount > #segVisPool then + -- NOTE: Do not change ":: any" I just hate annoying warning + segVisPool[segVisPoolCount] = { nil :: any, false } + end + return segVisPool[segVisPoolCount] +end + +local function ResetPools() + argsPoolCount = 0 + batchPoolCount = 0 + visPoolCount = 0 + hitVisPoolCount = 0 + segVisPoolCount = 0 +end + +-- ============================================================================ +-- Queued visualizations (flat, allocation-free) +-- ============================================================================ + +-- Hit/pierce visualization: pass fields directly, data table is pooled +local function QueueVisualizeHit( + castID: number, + atCF: CFrame, + wasPierce: boolean, + sub: boolean +) if not casts_VisualizeCasts[castID] then return end - if not queuedVisualizes[castID] then - queuedVisualizes[castID] = {} + local data = GetHitVisData() + data[1] = atCF + data[2] = wasPierce + data[3] = sub + + queuedVisCount += 1 + local entry = GetVis() + entry[1] = castID + entry[2] = data + entry[3] = 1 -- tag: 1 = hit data + queuedVisualizes[queuedVisCount] = entry +end + +-- Segment visualization: pass fields directly, data table is pooled +local function QueueVisualizeSegment( + castID: number, + castStartCFrame: CFrame, + sub: boolean +) + if not casts_VisualizeCasts[castID] then + return end - table.insert(queuedVisualizes[castID], visualizeData) + local data = GetSegVisData() + data[1] = castStartCFrame + data[2] = sub + + queuedVisCount += 1 + local entry = GetVis() + entry[1] = castID + entry[2] = data + entry[3] = 2 -- tag: 2 = segment data + queuedVisualizes[queuedVisCount] = entry end -local function FireQueuedVisualizes(unFiredVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData }}) - local sortedIDs = {} - for id in unFiredVisualizes do - table.insert(sortedIDs, id) - end - table.sort(sortedIDs) +-- Reusable variant table (consumed synchronously, safe to reuse) +local reusableVariant = {} :: CastVisualizerVariants + +local function FireQueuedVisualizes() + for i = 1, queuedVisCount do + local entry = queuedVisualizes[i] + local castID = entry[1] :: number + local data = entry[2] + local dataTag = entry[3] :: number - for _, castID in sortedIDs do - local visualizesList = unFiredVisualizes[castID] - if not visualizesList or not next(visualizesList) then + local settings = casts_VisualizeCastSettings[castID] + if not settings then continue end - for _, visualizeData in visualizesList do - -- VisualizeHit - if visualizeData.atCF then - if DebugLogging.Hit and not visualizeData.wasPierce and not visualizeData.sub then - print("Hit was successful. Terminating.") - end - if DebugLogging.Pierced and visualizeData.wasPierce and not visualizeData.sub then - print("Piercing function returned TRUE to pierce this part.") - end - DbgVisualizeHit(visualizeData.atCF, visualizeData.wasPierce, casts_VisualizeCastSettings[castID]) - continue - end - -- VisualizeCasts - if DebugLogging.Segment and visualizeData.sub then - print("Subcast visualization") - end - local castType = casts_CastType[castID] - local castVisualizer = Visualizers[castType] - if not castVisualizer then - continue + -- Hit visualization (tag 1) + if dataTag == 1 then + local atCF = data[1] :: CFrame + local wasPierce = data[2] :: boolean + local sub = data[3] :: boolean + + if DebugLogging.Hit + and not wasPierce + and not sub + then + print("Hit was successful. Terminating.") end - local visualizerVariant: CastVisualizerVariants - if castType == EnumCastTypes.Raycast then - visualizerVariant = { castLength = casts_RayDisplacement[castID] or 1 } - elseif castType == EnumCastTypes.Blockcast then - visualizerVariant = { size = (casts_CastVariant[castID] :: BlockcastVariant).Size } - else - visualizerVariant = { radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius } + + if DebugLogging.Pierced + and wasPierce + and not sub + then + print("Piercing function returned TRUE to pierce this part.") end - castVisualizer(visualizeData.castStartCFrame, casts_VisualizeCastSettings[castID], visualizerVariant) + + DbgVisualizeHit( + atCF, + wasPierce, + settings + ) + + continue + end + + -- Segment visualization (tag 2) + local castStartCFrame = data[1] :: CFrame + local sub = data[2] :: boolean + + if DebugLogging.Segment and sub then + print("Subcast visualization") + end + + local castType = casts_CastType[castID] + local visualizer = Visualizers[castType] + + if not visualizer then + continue end + + -- Reuse single variant table (consumed synchronously) + if castType == EnumCastTypes.Raycast then + reusableVariant.castLength = casts_RayDisplacement[castID] or 1 + elseif castType == EnumCastTypes.Blockcast then + reusableVariant.size = (casts_CastVariant[castID] :: BlockcastVariant).Size + else + reusableVariant.radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius + end + + visualizer( + castStartCFrame, + settings, + reusableVariant + ) end end -local function QueueEvent(castID: number, eventType: string, ...: any) - local args = { ... } - if not queuedEvents[castID] then - queuedEvents[castID] = {} +-- ============================================================================ +-- Queued events (flat, allocation-free) +-- ============================================================================ + +-- Event signatures have at most five payload values. Assign all slots so +-- nil holes cannot leave stale references behind (the # operator is undefined +-- for sparse arrays), and avoid select/vararg loops in the hot path. +local function QueueEvent( + castID: number, + eventType: number, + arg1: any, + arg2: any, + arg3: any, + arg4: any, + arg5: any +) + local args = GetArgs() + args[1] = arg1 + args[2] = arg2 + args[3] = arg3 + args[4] = arg4 + args[5] = arg5 + + queuedEventsCount += 1 + if queuedEventsCount > eventCapacity then + eventCapacity *= 2 + local expanded = buffer.create(eventCapacity * EVENT_STRIDE) + buffer.copy(expanded, 0, eventMetadata) + eventMetadata = expanded end - table.insert(queuedEvents[castID], { - eventType = eventType, - args = args - }) + local offset = (queuedEventsCount - 1) * EVENT_STRIDE + buffer.writef64(eventMetadata, offset, castID) + buffer.writeu8(eventMetadata, offset + 8, eventType) end -local function FireQueuedEvents(events: { [number]: { QueuedEventData } }) - local sortedIDs = {} - for id in events do - table.insert(sortedIDs, id) - end - table.sort(sortedIDs) +local function FireQueuedEvents() + -- Events are collected into a single per-frame batch so the worker only + -- crosses the Actor boundary once per frame instead of once per event. + local batch: { any } = {} + local batchCount = 0 + local batchCaster: any = nil + + for i = 1, queuedEventsCount do + local offset = (i - 1) * EVENT_STRIDE + local castID = buffer.readf64(eventMetadata, offset) + local numericType = buffer.readu8(eventMetadata, offset + 8) + local args = argsPool[i] - for _, castID in sortedIDs do - local eventList = events[castID] - if not eventList or not next(eventList) then + local cast = ActivesRef[castID] + + if not cast then continue end - for _, event in eventList do - local cast = ActivesRef[castID] - if not cast then + local caster = cast.Caster + + local moduleConfig = casts_FastCastEventsModuleConfig[castID] + local eventConfig = casts_FastCastEventsConfig[castID] + local fastCastEvents = casts_FastCastEvents[castID] + + if numericType == EVT_LENGTH_CHANGED then + local lastPoint = args[1] + local rayDirection = args[2] + local displacement = args[3] + local velocity = args[4] + local cosmetic = args[5] + + if eventConfig and eventConfig.UseLengthChanged then + batchCount += 1 + local record = GetBatchRecord() + record[1] = EVT_LENGTH_CHANGED + record[2] = cast + record[3] = args + record[4] = 5 + batch[batchCount] = record + batchCaster = caster + end + + if moduleConfig + and moduleConfig.UseLengthChanged + and fastCastEvents + and fastCastEvents.LengthChanged + then + fastCastEvents.LengthChanged( + cast, + lastPoint, + rayDirection, + displacement, + velocity, + cosmetic + ) + end + + elseif numericType == EVT_HIT then + local result = args[1] + local velocity = args[2] + local cosmetic = args[3] + + if eventConfig and eventConfig.UseHit then + batchCount += 1 + local record = GetBatchRecord() + record[1] = EVT_HIT + record[2] = cast + record[3] = args + record[4] = 3 + batch[batchCount] = record + batchCaster = caster + end + + if moduleConfig + and moduleConfig.UseHit + and fastCastEvents + and fastCastEvents.Hit + then + fastCastEvents.Hit( + cast, + result, + velocity, + cosmetic + ) + end + + elseif numericType == EVT_PIERCED then + local result = args[1] + local velocity = args[2] + local cosmetic = args[3] + + if eventConfig and eventConfig.UsePierced then + batchCount += 1 + local record = GetBatchRecord() + record[1] = EVT_PIERCED + record[2] = cast + record[3] = args + record[4] = 3 + batch[batchCount] = record + batchCaster = caster + end + + if moduleConfig + and moduleConfig.UsePierced + and fastCastEvents + and fastCastEvents.Pierced + then + fastCastEvents.Pierced( + cast, + result, + velocity, + cosmetic + ) + end + + elseif numericType == EVT_CAST_TERMINATING then + local callback = args[1] + + if cast._Destroying then continue end - local eventType: string = event.eventType - local args: { any } = event.args - - local caster = cast.Caster - local moduleConfig = casts_FastCastEventsModuleConfig[castID] - local eventConfig = casts_FastCastEventsConfig[castID] - local fastCastEvents = casts_FastCastEvents[castID] - - if eventType == "LengthChanged" then - local lastPoint = args[1] - local rayDir = args[2] - local rayDisplacement = args[3] - local segmentVelocity, CosmeticBulletObject = args[4], args[5] - - if eventConfig and eventConfig.UseLengthChanged then - caster.Output:Fire("LengthChanged", cast, lastPoint, rayDir, rayDisplacement, segmentVelocity, CosmeticBulletObject) - end - - if moduleConfig and moduleConfig.UseLengthChanged and fastCastEvents and fastCastEvents.LengthChanged then - fastCastEvents.LengthChanged(cast, lastPoint, rayDir, rayDisplacement, segmentVelocity, CosmeticBulletObject) - end - - elseif eventType == "Hit" then - local result = args[1] - local velocity = args[2] - local cosmeticBulletObject = args[3] - - if eventConfig and eventConfig.UseHit then - caster.Output:Fire("Hit", cast, result, velocity, cosmeticBulletObject) - end - - if moduleConfig and moduleConfig.UseHit and fastCastEvents and fastCastEvents.Hit then - fastCastEvents.Hit(cast, result, velocity, cosmeticBulletObject) - end - - elseif eventType == "Pierced" then - local result = args[1] - local velocity = args[2] - local cosmeticBulletObject = args[3] - - if eventConfig and eventConfig.UsePierced then - caster.Output:Fire("Pierced", cast, result, velocity, cosmeticBulletObject) - end - - if moduleConfig and moduleConfig.UsePierced and fastCastEvents and fastCastEvents.Pierced then - fastCastEvents.Pierced(cast, result, velocity, cosmeticBulletObject) - end - - elseif eventType == "CastTerminating" then - local castTerminatingfn = args[1] - TerminateCast(cast, castTerminatingfn) + cast._Destroying = true + + if eventConfig + and eventConfig.UseCastTerminating + then + batchCount += 1 + local record = GetBatchRecord() + record[1] = EVT_CAST_TERMINATING + record[2] = cast + record[3] = false + record[4] = 0 + batch[batchCount] = record + batchCaster = caster + end + + if callback then + callback(cast) end + + ActivesRef[castID] = nil + ParallelSimulation_Unregister(castID) end end + + if batchCount > 0 then + batchCaster.Output:Fire("Batch", batch) + end +end + +-- ============================================================================ +-- Movement +-- ============================================================================ + +-- Pre-allocated BulkMoveTo arrays (reused every frame) +local bulkParts: { Instance } = {} +local bulkCframes: { CFrame } = {} +local bulkCount: number = 0 +local cosmeticFrame: number = 0 + +-- Cosmetic parts can be pushed at a lower rate than the simulation. Moving +-- thousands of parts (and replicating their CFrames) is the single most +-- expensive per-frame cost once cosmetics exist. +local function ShouldUpdateCosmetics(): boolean + local interval = Config.CosmeticUpdateInterval or 1 + if interval <= 1 then + return true + end + + cosmeticFrame += 1 + return cosmeticFrame % interval == 0 end local function BulkMoveTo() - if CurrentMovementMode ~= "BulkMoveTo" or not MovementEnabled then + if CurrentMovementMode ~= "BulkMoveTo" + or not MovementEnabled + then + return + end + + -- Nothing to move if no cast has a cosmetic part. + if castsWithCosmetics == 0 then + return + end + + if not ShouldUpdateCosmetics() then return end - local parts = table.create(#casts_ID) - local cframes = table.create(#casts_ID) + bulkCount = 0 for _, id in casts_ID do - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and cosmeticPart.Parent then - table.insert(parts, cosmeticPart) - table.insert(cframes, casts_CFrame[id]) + local rayInfo = casts_RayInfo[id] + local cosmetic = rayInfo and rayInfo.CosmeticBulletObject + + if cosmetic and cosmetic.Parent then + bulkCount += 1 + bulkParts[bulkCount] = cosmetic + bulkCframes[bulkCount] = casts_CFrame[id] end end - if #parts > 0 then - workspace:BulkMoveTo(parts, cframes, Enum.BulkMoveMode.FireCFrameChanged) + if bulkCount > 0 then + workspace:BulkMoveTo( + bulkParts, + bulkCframes, + Enum.BulkMoveMode.FireCFrameChanged + ) end end local function UpdateMotor6Ds() - if CurrentMovementMode ~= "Motor6D" or not MovementEnabled then + if CurrentMovementMode ~= "Motor6D" + or not MovementEnabled + then + return + end + + if not ShouldUpdateCosmetics() then return end @@ -445,477 +796,832 @@ local function UpdateMotor6Ds() end end --- ParallelSimulation +-- ============================================================================ +-- Event callback helpers +-- ============================================================================ -local ParallelSimulation = {} -ParallelSimulation.Connection = nil :: RBXScriptConnection? +local function GetEventCallbacks(id: number) + local moduleConfig = casts_FastCastEventsModuleConfig[id] + local events = casts_FastCastEvents[id] -function ParallelSimulation.Init(baseCastRef: any) + if not moduleConfig or not events then + return nil, nil + end - if ActivesRef ~= nil then - warn("[FastCast2] ParallelSimulation already initialized. Skipping.") - return - end + local canPierce = + moduleConfig.UseCanPierce + and events.CanPierce + or nil - BaseCastRef = baseCastRef - ActivesRef = baseCastRef.Actives + local terminating = + moduleConfig.UseCastTerminating + and events.CastTerminating + or nil + + return canPierce, terminating end -function ParallelSimulation.Register(cast: any) - local id = cast.ID +-- ============================================================================ +-- Simulation helpers +-- ============================================================================ - casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 - casts_DistanceCovered[id] = 0 - casts_HighFidelitySegmentSize[id] = cast.StateInfo.HighFidelitySegmentSize or 0.1 - casts_HighFidelityBehavior[id] = cast.StateInfo.HighFidelityBehavior or 0 - casts_IsActivelySimulatingPierce[id] = false - casts_IsActivelyResimulating[id] = false - casts_CancelHighResCast[id] = false - casts_Trajectory[id] = cast.StateInfo.Trajectory - casts_FastCastEventsModuleConfig[id] = cast.StateInfo.FastCastEventsModuleConfig - casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - casts_RayInfo[id] = cast.RayInfo - casts_UserData[id] = cast.UserData - casts_CastType[id] = cast.CastVariant.CastType - casts_CastVariant[id] = cast.CastVariant - casts_MaxDistance[id] = cast.RayInfo.MaxDistance or DEFAULT_MAX_DISTANCE - table.insert(casts_ID, id) - casts_ID_Index[id] = #casts_ID +local function QueueHit( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean, + castTerminatingfn: TypeDef.OnCastTerminatingFunction? +) + QueueEvent( + id, + EVT_HIT, + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - local eventModule = cast.RayInfo.FastCastEventsModule - casts_FastCastEvents[id] = (typeof(eventModule) == "Instance" and eventModule:IsA("ModuleScript")) and require(eventModule) or nil + QueueVisualizeHit( + id, + CFrame.new(result.Position), + false, + sub + ) - local position = GetPositionAtTime( - casts_TotalRunTime[id], - casts_Trajectory[id].Origin, - casts_Trajectory[id].InitialVelocity, - casts_Trajectory[id].Acceleration + QueueEvent( + id, + EVT_CAST_TERMINATING, + castTerminatingfn ) - casts_CFrame[id] = CFrame.new(position) +end - cast.CFrame = casts_CFrame[id] +local function QueuePierce( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) + QueueEvent( + id, + EVT_PIERCED, + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - if CurrentMovementMode == "Motor6D" and MovementEnabled then - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and BaseCastRef and BaseCastRef._GetMotor6D then - local motor6d = BaseCastRef:_GetMotor6D(cosmeticPart) - casts_ActiveMotor6Ds[id] = motor6d - end + QueueVisualizeHit( + id, + CFrame.new(result.Position), + true, + sub + ) +end + +local function ShouldPierce( + id: number, + result: RaycastResult, + velocity: Vector3, + canPierce: TypeDef.CanPierceFunction? +): boolean + if not canPierce then + return false end - queuedEvents[id] = {} + return canPierce( + ActivesRef[id], + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) end -function ParallelSimulation.Unregister(castID: number) - casts_TotalRunTime[castID] = nil - casts_DistanceCovered[castID] = nil - casts_HighFidelitySegmentSize[castID] = nil - casts_HighFidelityBehavior[castID] = nil - casts_IsActivelySimulatingPierce[castID] = nil - casts_IsActivelyResimulating[castID] = nil - casts_CancelHighResCast[castID] = nil - casts_Trajectory[castID] = nil - casts_FastCastEventsModuleConfig[castID] = nil - casts_FastCastEventsConfig[castID] = nil - casts_VisualizeCasts[castID] = nil - casts_VisualizeCastSettings[castID] = nil - casts_RayInfo[castID] = nil - casts_UserData[castID] = nil - casts_CFrame[castID] = nil - casts_CastType[castID] = nil - casts_CastVariant[castID] = nil - casts_MaxDistance[castID] = nil - casts_RayDisplacement[castID] = nil +-- ============================================================================ +-- Automatic high-fidelity resimulation +-- ============================================================================ - if casts_ActiveMotor6Ds[castID] then - if BaseCastRef and BaseCastRef._ReturnMotor6D then - BaseCastRef:_ReturnMotor6D(casts_ActiveMotor6Ds[castID]) - end - casts_ActiveMotor6Ds[castID] = nil - end +local function ResimulateHit( + id: number, + delta: number, + lastDelta: number, + origin: Vector3, + initialVelocity: Vector3, + acceleration: Vector3, + targetWorldRoot: WorldRoot, + castHandler: any, + canPierce: TypeDef.CanPierceFunction?, + castTerminatingfn: TypeDef.OnCastTerminatingFunction?, + fallbackResult: RaycastResult, + fallbackVelocity: Vector3 +): boolean + + casts_IsActivelyResimulating[id] = true + + local rayDisplacement = casts_RayDisplacement[id] + + local segmentCount = math.max( + 1, + math.floor( + rayDisplacement + / casts_HighFidelitySegmentSize[id] + ) + ) - local idx = casts_ID_Index[castID] - if idx then - local lastID = casts_ID[#casts_ID] - casts_ID[idx] = lastID - casts_ID_Index[lastID] = idx - casts_ID[#casts_ID] = nil - casts_ID_Index[castID] = nil + local timeIncrement = delta / segmentCount + + if DebugLogging.Calculation then + print( + "Performing subcast! Time increment: " + .. timeIncrement + .. ", num segments: " + .. segmentCount + ) end - casts_FastCastEvents[castID] = nil - queuedEvents[castID] = nil - queuedVisualizes[castID] = nil -end + local lastPierced = false -function ParallelSimulation.SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") - local oldMode = CurrentMovementMode - CurrentMovementMode = mode - MovementEnabled = enabled + for segmentIndex = 1, segmentCount do + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break + end - if oldMode == "Motor6D" and mode ~= "Motor6D" then - for id, motor6d in casts_ActiveMotor6Ds do - if BaseCastRef and BaseCastRef._ReturnMotor6D then - BaseCastRef:_ReturnMotor6D(motor6d) - end - casts_ActiveMotor6Ds[id] = nil + local subTime = + lastDelta + timeIncrement * segmentIndex + + local subPosition = GetPositionAtTime( + subTime, + origin, + initialVelocity, + acceleration + ) + + local subVelocity = GetVelocityAtTime( + subTime, + initialVelocity, + acceleration + ) + + local subDirection = subVelocity * timeIncrement + + -- Only offset after the previous subcast pierced + local subOrigin = if lastPierced + then subPosition + subVelocity.Unit * RAY_SEARCH_OFFSET + else subPosition + + lastPierced = false + + local subResult = castHandler( + targetWorldRoot, + subOrigin, + subDirection, + casts_RayInfo[id].Parameters, + casts_CastVariant[id] + ) + + if casts_VisualizeCasts[id] then + QueueVisualizeSegment( + id, + CFrame.new( + subPosition, + subPosition + subVelocity + ), + true + ) end - end - if mode == "Motor6D" and enabled and oldMode ~= "Motor6D" then - for _, id in casts_ID do - if not casts_ActiveMotor6Ds[id] then - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and BaseCastRef and BaseCastRef._GetMotor6D then - local motor6d = BaseCastRef:_GetMotor6D(cosmeticPart) - casts_ActiveMotor6Ds[id] = motor6d - end - end + if not subResult then + continue + end + + if ShouldPierce( + id, + subResult, + subVelocity, + canPierce + ) then + QueuePierce( + id, + subResult, + subVelocity, + true + ) + + lastPierced = true + continue end + + casts_IsActivelyResimulating[id] = false + + QueueHit( + id, + subResult, + subVelocity, + true, + castTerminatingfn + ) + + return true end + + casts_IsActivelyResimulating[id] = false + + -- The original cast hit, but none of the + -- high-resolution subcasts did. + QueueHit( + id, + fallbackResult, + fallbackVelocity, + false, + castTerminatingfn + ) + + return true end --- RS +-- ============================================================================ +-- Single simulation step +-- ============================================================================ + local function SimulateCast( id: number, - delta: number, - FastCastEvents + delta: number ) if DebugLogging.Casting then print("Casting for frame - SimulateCast") end local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] local origin = trajectory.Origin - local totalDelta = casts_TotalRunTime[id] - trajectory.StartTime local initialVelocity = trajectory.InitialVelocity local acceleration = trajectory.Acceleration - local lastPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) + local previousTime = casts_TotalRunTime[id] - trajectory.StartTime - local lastDelta = casts_TotalRunTime[id] - trajectory.StartTime + local lastPoint = GetPositionAtTime( + previousTime, + origin, + initialVelocity, + acceleration + ) casts_TotalRunTime[id] += delta - totalDelta = casts_TotalRunTime[id] - trajectory.StartTime + local currentTime = casts_TotalRunTime[id] - trajectory.StartTime - local currentTarget = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) - local segmentVelocity = GetVelocityAtTime(totalDelta, initialVelocity, acceleration) - local totalDisplacement = currentTarget - lastPoint + local velocity = GetVelocityAtTime( + currentTime, + initialVelocity, + acceleration + ) - local rayDir = totalDisplacement + -- p(t1)-p(t0) = (v0 + a*(t0+t1)/2)*(t1-t0). + -- Avoid a second position evaluation and cancellation of large world + -- coordinates. Use the actual elapsed interval after runtime rounding. + local displacement = (initialVelocity + acceleration * ((previousTime + currentTime) * 0.5)) + * (currentTime - previousTime) + local displacementMagnitude = displacement.Magnitude - local targetWorldRoot = casts_RayInfo[id].WorldRoot + if displacementMagnitude <= EPSILON then + casts_DistanceCovered[id] += displacementMagnitude + return + end - local castHandler = castHandlers[casts_CastVariant[id].CastType] - local resultOfCast = castHandler(targetWorldRoot, lastPoint, rayDir, casts_RayInfo[id].Parameters, casts_CastVariant[id]) + local worldRoot = rayInfo.WorldRoot + local handler = castHandlers[variant.CastType] - local point = currentTarget - local part: Instance? = nil + local result = handler( + worldRoot, + lastPoint, + displacement, + rayInfo.Parameters, + variant + ) - if resultOfCast ~= nil then - point = resultOfCast.Position - part = resultOfCast.Instance - end + local rayDisplacement = if result + then (result.Position - lastPoint).Magnitude + else displacementMagnitude - local rayDisplacement = (point - lastPoint).Magnitude casts_RayDisplacement[id] = rayDisplacement - casts_CFrame[id] = (if rayDir ~= Vector3.new() then CFrame.new(lastPoint, lastPoint + rayDir) else CFrame.new(lastPoint)) * CFrame.new(0, 0, -rayDisplacement / 2) + -- Build the midpoint transform directly instead of multiplying two + -- CFrames. lookAlong avoids losing direction when origin + direction + -- rounds back to origin at large world coordinates. + local unitDirection = displacement / displacementMagnitude + casts_CFrame[id] = CFrame.lookAlong( + lastPoint + unitDirection * (rayDisplacement * 0.5), + unitDirection + ) - QueueEvent(id, "LengthChanged", lastPoint, rayDir.Unit, rayDisplacement, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) + if casts_WantLengthChanged[id] then + QueueEvent( + id, + EVT_LENGTH_CHANGED, + lastPoint, + unitDirection, + rayDisplacement, + velocity, + rayInfo.CosmeticBulletObject + ) + end casts_DistanceCovered[id] += rayDisplacement - if delta > 0 then - QueueVisualize( + if delta > 0 and casts_VisualizeCasts[id] then + QueueVisualizeSegment( id, - { - castStartCFrame = CFrame.new(lastPoint, lastPoint+rayDir), - sub = false - } :: any + CFrame.lookAlong(lastPoint, unitDirection), + false ) end - local canPierceCheckfn: TypeDef.CanPierceFunction? = nil - local castTerminatingfn: TypeDef.OnCastTerminatingFunction? = nil + -- ------------------------------------------------------------------------ + -- No hit. + -- ------------------------------------------------------------------------ + + if not result then + if casts_DistanceCovered[id] >= casts_MaxDistance[id] then + local _, castTerminatingfn = GetEventCallbacks(id) + QueueEvent( + id, + EVT_CAST_TERMINATING, + castTerminatingfn + ) + end - if FastCastEvents then - canPierceCheckfn = casts_FastCastEventsModuleConfig[id].UseCanPierce and FastCastEvents.CanPierce or nil - castTerminatingfn = casts_FastCastEventsModuleConfig[id].UseCastTerminating and FastCastEvents.CastTerminating or nil + return end - -- NOTE: Please dont remove "part and" - -- Why? basically when part doesn't exist it will do nothing, but removing "part and" will break the logic - -- You can't do anything about it - if part and part ~= casts_RayInfo[id].CosmeticBulletObject then - if - canPierceCheckfn == nil - or canPierceCheckfn(ActivesRef[id], resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) == false - then + -- ------------------------------------------------------------------------ + -- Cosmetic bullet collision. + -- ------------------------------------------------------------------------ - casts_IsActivelyResimulating[id] = false + if result.Instance == rayInfo.CosmeticBulletObject then + return + end - if - casts_HighFidelityBehavior[id] == FastCastEnums.HighFidelityBehavior.Automatic - and casts_HighFidelitySegmentSize[id] > 0 - then - casts_CancelHighResCast[id] = false - - if casts_IsActivelyResimulating[id] then - QueueEvent(id, "CastTerminating", castTerminatingfn) - - warn( - "Cascading cast lag encountered! The caster attempted to perform a high fidelity cast before the previous one completed, resulting in exponential cast lag. Consider increasing HighFidelitySegmentSize." - ) - return - end - - casts_IsActivelyResimulating[id] = true - - local numSegmentsDecimal = rayDisplacement / casts_HighFidelitySegmentSize[id] - local numSegmentsReal = math.floor(numSegmentsDecimal) - --local realSegmentLength = rayDisplacement / numSegmentsReal - - if numSegmentsReal == 0 then - numSegmentsReal = 1 - end - - local timeIncrement = delta / numSegmentsReal - local subHitFound = false - - for segmentIndex = 1, numSegmentsReal do - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end - - local subPosition = GetPositionAtTime( - lastDelta + (timeIncrement * segmentIndex), - origin, - initialVelocity, - acceleration - ) - local subVelocity = GetVelocityAtTime( - lastDelta + (timeIncrement * segmentIndex), - initialVelocity, - acceleration - ) - local subRayDir = subVelocity * timeIncrement - local subResult = castHandler( - targetWorldRoot, - subPosition, - subRayDir, - casts_RayInfo[id].Parameters, - casts_CastVariant[id] - ) - - - --local subDisplacement = (subPosition - (subPosition + subVelocity)).Magnitude - - if subResult ~= nil then - subHitFound = true - - QueueVisualize(id, { - castStartCFrame = CFrame.new(subPosition, subPosition + subVelocity), - sub = true - } :: any) - - if - canPierceCheckfn == nil - or canPierceCheckfn(ActivesRef[id], subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) == false - then - casts_IsActivelyResimulating[id] = false - QueueEvent(id, "Hit", subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueEvent(id, "CastTerminating", castTerminatingfn) - QueueVisualize(id, { - atCF = CFrame.new(subResult.Position), - wasPierce = false, - sub = true - } :: any) - return - else - QueueEvent(id, "Pierced", subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(subResult.Position), - wasPierce = true, - sub = true - } :: any) - end - else - QueueVisualize(id, { - castStartCFrame = CFrame.new(subPosition, subPosition+subVelocity), - sub = true - } :: any) - end - end - casts_IsActivelyResimulating[id] = false - if not subHitFound then - QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - } :: any) - QueueEvent(id, "CastTerminating", castTerminatingfn) - return - end - else - QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - } :: any) - QueueEvent(id, "CastTerminating", castTerminatingfn) - - return - end - else - QueueEvent(id, "Pierced", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = true - } :: any) - end + local canPierce, castTerminatingfn = GetEventCallbacks(id) + + -- ------------------------------------------------------------------------ + -- Pierce. + -- ------------------------------------------------------------------------ + + if ShouldPierce( + id, + result, + velocity, + canPierce + ) then + QueuePierce( + id, + result, + velocity, + false + ) + + return end - if casts_DistanceCovered[id] >= casts_MaxDistance[id] then - if resultOfCast then - QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - }:: any) + -- ------------------------------------------------------------------------ + -- Hit. + -- ------------------------------------------------------------------------ + + if casts_HighFidelityBehavior[id] + == FastCastEnums.HighFidelityBehavior.Automatic + and casts_HighFidelitySegmentSize[id] > 0 + then + if casts_IsActivelyResimulating[id] then + QueueEvent( + id, + EVT_CAST_TERMINATING, + castTerminatingfn + ) + + warn( + "Cascading cast lag encountered! " + .. "The caster attempted to perform a high fidelity " + .. "cast before the previous one completed. " + .. "Consider increasing HighFidelitySegmentSize." + ) + + return end - QueueEvent(id, "CastTerminating", castTerminatingfn) + + local lastDelta = previousTime + + ResimulateHit( + id, + delta, + lastDelta, + origin, + initialVelocity, + acceleration, + worldRoot, + handler, + canPierce, + castTerminatingfn, + result, + velocity + ) + + return end + + QueueHit( + id, + result, + velocity, + false, + castTerminatingfn + ) end -local function UpdateCasts(delta: number) - for _, id in casts_ID do - if DebugLogging.Casting then - print("Casting for frame - UpdateCasts") - end +-- ============================================================================ +-- Always high-fidelity simulation +-- ============================================================================ - if ActivesRef[id] and ActivesRef[id]._Destroying then - continue +local function SimulateAlwaysHighFidelity( + id: number, + delta: number +) + if casts_IsActivelyResimulating[id] then + local _, terminating = GetEventCallbacks(id) + + QueueEvent( + id, + EVT_CAST_TERMINATING, + terminating + ) + + warn( + "Cascading cast lag encountered! " + .. "The caster attempted to perform a high fidelity " + .. "cast before the previous one completed. " + .. "Consider increasing HighFidelitySegmentSize." + ) + + return + end + + casts_IsActivelyResimulating[id] = true + + local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] + + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration + + local currentTime = casts_TotalRunTime[id] - trajectory.StartTime + + local lastPoint = GetPositionAtTime( + currentTime, + origin, + initialVelocity, + acceleration + ) + + -- Temporarily advance time to determine + -- the approximate distance for this frame. + casts_TotalRunTime[id] += delta + + local nextTime = casts_TotalRunTime[id] - trajectory.StartTime + + local currentPoint = GetPositionAtTime( + nextTime, + origin, + initialVelocity, + acceleration + ) + + local currentVelocity = GetVelocityAtTime( + nextTime, + initialVelocity, + acceleration + ) + + local totalDisplacement = currentPoint - lastPoint + + local direction = + totalDisplacement.Unit + * currentVelocity.Magnitude + * delta + + local handler = castHandlers[variant.CastType] + + local resultOfCast = handler( + rayInfo.WorldRoot, + lastPoint, + direction, + rayInfo.Parameters, + variant + ) + + local endpoint = + resultOfCast + and resultOfCast.Position + or currentPoint + + local distance = (endpoint - lastPoint).Magnitude + + casts_TotalRunTime[id] -= delta + + local segmentCount = math.max( + 1, + math.floor( + distance + / casts_HighFidelitySegmentSize[id] + ) + ) + + local timeIncrement = delta / segmentCount + + if DebugLogging.Calculation then + print( + "Performing subcast! Time increment: " + .. timeIncrement + .. ", num segments: " + .. segmentCount + ) + end + + for segmentIndex = 1, segmentCount do + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break end - local Trajectory: TypeDef.CastTrajectory = casts_Trajectory[id] + if not ActivesRef[id] then + casts_IsActivelyResimulating[id] = false + return + end - if casts_HighFidelitySegmentSize[id] <= 0 then - casts_HighFidelitySegmentSize[id] = 0.1 + if DebugLogging.Segment then + print( + "[" + .. segmentIndex + .. "] Subcast of time increment " + .. timeIncrement + ) end - local FastCastEvents: TypeDef.FastCastEvents = casts_FastCastEvents[id] + SimulateCast( + id, + timeIncrement + ) + end - if casts_HighFidelityBehavior[id] == FastCastEnums.HighFidelityBehavior.Always then - local castTerminatingfn: TypeDef.OnCastTerminatingFunction? = nil - if FastCastEvents then - castTerminatingfn = casts_FastCastEventsModuleConfig[id].UseCastTerminating - and FastCastEvents.CastTerminating - or nil - end + casts_IsActivelyResimulating[id] = false +end - if casts_IsActivelyResimulating[id] then - QueueEvent(id, "CastTerminating", castTerminatingfn) - warn("Casading cast lag encountered! The caster attempted to perform a high fidelity cast before the previous one completed, resulting in exponential cast lag. Consider increasing HighFidelitySegmentSize.") - continue - end - casts_IsActivelyResimulating[id] = true +-- ============================================================================ +-- Registration +-- ============================================================================ + +function ParallelSimulation_Register(cast: any) + local id = cast.ID + + casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 + + casts_DistanceCovered[id] = 0 + + casts_HighFidelitySegmentSize[id] = cast.StateInfo.HighFidelitySegmentSize or 0.1 + + casts_HighFidelityBehavior[id] = cast.StateInfo.HighFidelityBehavior or 0 - local origin = Trajectory.Origin - local totalDelta = casts_TotalRunTime[id] - Trajectory.StartTime - local initialVelocity = Trajectory.InitialVelocity - local acceleration = Trajectory.Acceleration + casts_IsActivelySimulatingPierce[id] = false + casts_IsActivelyResimulating[id] = false + casts_CancelHighResCast[id] = false - local lastPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) + casts_Trajectory[id] = cast.StateInfo.Trajectory - casts_TotalRunTime[id] += delta + casts_FastCastEventsModuleConfig[id] = cast.StateInfo.FastCastEventsModuleConfig - totalDelta = casts_TotalRunTime[id] - Trajectory.StartTime + casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - local currentPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) - local currentVelocity = GetVelocityAtTime(totalDelta, initialVelocity, acceleration) - local totalDisplacement = currentPoint - lastPoint + casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - local rayDir = totalDisplacement.Unit * currentVelocity.Magnitude * delta + casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings + + casts_RayInfo[id] = cast.RayInfo + casts_UserData[id] = cast.UserData - local RayInfo = casts_RayInfo[id] - local targetWorldRoot = RayInfo.WorldRoot + casts_CastType[id] = cast.CastVariant.CastType - local castHandler = castHandlers[casts_CastVariant[id].CastType] - local resultOfCast = castHandler(targetWorldRoot, lastPoint, rayDir, RayInfo.Parameters, casts_CastVariant[id]) + casts_CastVariant[id] = cast.CastVariant - local point = currentPoint - if resultOfCast ~= nil then - point = resultOfCast.Position - end + casts_MaxDistance[id] = + cast.RayInfo.MaxDistance + or DEFAULT_MAX_DISTANCE - local rayDisplacement = (point - lastPoint).Magnitude - casts_TotalRunTime[id] -= delta + table.insert(casts_ID, id) + casts_ID_Index[id] = #casts_ID - local numSegmentsDecimal = rayDisplacement / casts_HighFidelitySegmentSize[id] - local numSegmentsReal = math.floor(numSegmentsDecimal) - if numSegmentsReal == 0 then - numSegmentsReal = 1 - end + -- Load optional event module. + local eventModule = cast.RayInfo.FastCastEventsModule - local timeIncrement = delta / numSegmentsReal + if typeof(eventModule) == "Instance" + and eventModule:IsA("ModuleScript") + then + casts_FastCastEvents[id] = require(eventModule) + else + casts_FastCastEvents[id] = nil + end - if DebugLogging.Calculation then - print("Performing subcast! Time increment: " .. timeIncrement .. ", num segments: " .. numSegmentsReal) - end + local eventConfig = casts_FastCastEventsConfig[id] + local moduleConfig = casts_FastCastEventsModuleConfig[id] + local moduleEvents = casts_FastCastEvents[id] + + casts_WantLengthChanged[id] = + (eventConfig ~= nil and eventConfig.UseLengthChanged == true) + or ( + moduleConfig ~= nil + and moduleConfig.UseLengthChanged == true + and moduleEvents ~= nil + and moduleEvents.LengthChanged ~= nil + ) + + local hasCosmetic = cast.RayInfo.CosmeticBulletObject ~= nil + casts_HasCosmetic[id] = hasCosmetic + if hasCosmetic then + castsWithCosmetics += 1 + end + + local trajectory = casts_Trajectory[id] + + local position = GetPositionAtTime( + casts_TotalRunTime[id], + trajectory.Origin, + trajectory.InitialVelocity, + trajectory.Acceleration + ) + + casts_CFrame[id] = CFrame.new(position) + cast.CFrame = casts_CFrame[id] + + if CurrentMovementMode == "Motor6D" + and MovementEnabled + then + local cosmetic = casts_RayInfo[id].CosmeticBulletObject + + if cosmetic + and BaseCastRef + and BaseCastRef._GetMotor6D + then + casts_ActiveMotor6Ds[id] = + BaseCastRef:_GetMotor6D(cosmetic) + end + end +end + +function ParallelSimulation_Unregister(castID: number) + casts_TotalRunTime[castID] = nil + casts_DistanceCovered[castID] = nil + + casts_HighFidelitySegmentSize[castID] = nil + casts_HighFidelityBehavior[castID] = nil + + casts_IsActivelySimulatingPierce[castID] = nil + casts_IsActivelyResimulating[castID] = nil + casts_CancelHighResCast[castID] = nil + + casts_Trajectory[castID] = nil + + casts_FastCastEventsModuleConfig[castID] = nil + casts_FastCastEventsConfig[castID] = nil + casts_FastCastEvents[castID] = nil + + casts_WantLengthChanged[castID] = nil + if casts_HasCosmetic[castID] then + castsWithCosmetics -= 1 + end + casts_HasCosmetic[castID] = nil + + casts_VisualizeCasts[castID] = nil + casts_VisualizeCastSettings[castID] = nil + + casts_RayInfo[castID] = nil + casts_UserData[castID] = nil + + casts_CFrame[castID] = nil + casts_CastType[castID] = nil + casts_CastVariant[castID] = nil + + casts_MaxDistance[castID] = nil + casts_RayDisplacement[castID] = nil + + if casts_ActiveMotor6Ds[castID] then + if BaseCastRef + and BaseCastRef._ReturnMotor6D + then + BaseCastRef:_ReturnMotor6D( + casts_ActiveMotor6Ds[castID] + ) + end + + casts_ActiveMotor6Ds[castID] = nil + end - local cast_nil = false + local index = casts_ID_Index[castID] - for segmentIndex = 1, numSegmentsReal do + if index then + local lastID = casts_ID[#casts_ID] - -- In case when cast Destroyed or not exist - if ActivesRef[id] == nil then - cast_nil = true - end + casts_ID[index] = lastID + casts_ID_Index[lastID] = index + table.remove(casts_ID, #casts_ID) + casts_ID_Index[castID] = nil + end +end - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end +-- ============================================================================ +-- Movement configuration +-- ============================================================================ - if DebugLogging.Segment then - print("[" .. segmentIndex .. "] Subcast of time increment " .. timeIncrement) - end +local function SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" +) + local oldMode = CurrentMovementMode + + CurrentMovementMode = mode + MovementEnabled = enabled - SimulateCast(id, timeIncrement, FastCastEvents) + if oldMode == "Motor6D" + and mode ~= "Motor6D" + then + for id, motor6d in casts_ActiveMotor6Ds do + if BaseCastRef + and BaseCastRef._ReturnMotor6D + then + BaseCastRef:_ReturnMotor6D(motor6d) end - if cast_nil then - casts_IsActivelyResimulating[id] = false + casts_ActiveMotor6Ds[id] = nil + end + end + + if mode == "Motor6D" + and enabled + and oldMode ~= "Motor6D" + then + for _, id in casts_ID do + if casts_ActiveMotor6Ds[id] then continue end - -- Double check again - if ActivesRef[id] == nil then - casts_IsActivelyResimulating[id] = false - continue + local cosmetic = + casts_RayInfo[id].CosmeticBulletObject + + if cosmetic + and BaseCastRef + and BaseCastRef._GetMotor6D + then + casts_ActiveMotor6Ds[id] = + BaseCastRef:_GetMotor6D(cosmetic) end - casts_IsActivelyResimulating[id] = false - else - SimulateCast(id, delta, FastCastEvents) end end +end - task.synchronize() +-- ============================================================================ +-- Main update +-- ============================================================================ - -- Write-back SoA -> ActiveCast so ActivesRef[id] has current values +local function UpdateCasts(delta: number) for _, id in casts_ID do local cast = ActivesRef[id] + + if cast and cast._Destroying then + continue + end + + if not casts_Trajectory[id] then + continue + end + + if casts_HighFidelitySegmentSize[id] <= 0 then + casts_HighFidelitySegmentSize[id] = 0.1 + end + + local behavior = casts_HighFidelityBehavior[id] + + if behavior == FastCastEnums.HighFidelityBehavior.Always then + SimulateAlwaysHighFidelity( + id, + delta + ) + else + SimulateCast( + id, + delta + ) + end + + -- Write-back SoA -> ActiveCast (merged to avoid second iteration) if cast then cast.StateInfo.TotalRuntime = casts_TotalRunTime[id] cast.StateInfo.DistanceCovered = casts_DistanceCovered[id] @@ -923,31 +1629,97 @@ local function UpdateCasts(delta: number) end end - -- Motor6D, BulkMoveTo, FireQueuedVisualizes, FireQueuedEvents - -- NOTE: Do not change these if you don't knows how it works + -- ========================================================================= + -- Synchronize back to the serial scheduler. + -- ========================================================================= + + task.synchronize() + + -- ========================================================================= + -- Main-thread-only work. + -- ========================================================================= UpdateMotor6Ds() BulkMoveTo() - local eventsToProcess = queuedEvents - queuedEvents = {} - local visualizesToProcess = queuedVisualizes - queuedVisualizes = {} + FireQueuedVisualizes() + FireQueuedEvents() - FireQueuedVisualizes(visualizesToProcess) - FireQueuedEvents(eventsToProcess) + -- Reset pool counters (reuse tables next frame) + ResetPools() + queuedEventsCount = 0 + queuedVisCount = 0 +end + +-- ============================================================================ +-- Public API +-- ============================================================================ + +local ParallelSimulation = {} + +ParallelSimulation.Connection = nil :: RBXScriptConnection? + +function ParallelSimulation.Init(baseCastRef: any) + if ActivesRef ~= nil then + warn( + "[FastCast2] ParallelSimulation already initialized. Skipping." + ) + + return + end + + BaseCastRef = baseCastRef + ActivesRef = baseCastRef.Actives + + -- Stagger each worker's cosmetic update phase so the workers do not all + -- call BulkMoveTo on the same frame (which produces heavy spikes). + local interval = Config.CosmeticUpdateInterval or 1 + if interval > 1 and baseCastRef.Actor then + local workerIndex = baseCastRef.Actor:GetAttribute("FastCastWorkerIndex") + + if type(workerIndex) == "number" then + cosmeticFrame = workerIndex % interval + end + end +end + +function ParallelSimulation.Register(cast: any) + ParallelSimulation_Register(cast) +end + +function ParallelSimulation.Unregister(castID: number) + ParallelSimulation_Unregister(castID) +end + +function ParallelSimulation.SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" +) + SetMovementModeEnabled(enabled, mode) end function ParallelSimulation.Start() if ParallelSimulation.Connection then - warn("Already started") + warn("[FastCast2] ParallelSimulation already started.") return end - if RS:IsClient() then - ParallelSimulation.Connection = RS.PreSimulation:ConnectParallel(UpdateCasts) + if Config.SimulationMode == "Fixed" then + -- Fixed timestep: the engine may run several steps per frame to keep + -- up at this frequency, which multiplies work when the FPS is low. + ParallelSimulation.Connection = RS:BindToSimulation(function(dt) + task.desynchronize() + UpdateCasts(dt) + end, Config.FixedSimulationFrequency) else - ParallelSimulation.Connection = RS.Heartbeat:ConnectParallel(UpdateCasts) + -- One step per frame. `UpdateCasts` synchronizes before main-thread work. + if RS:IsClient() then + ParallelSimulation.Connection = + RS.PreSimulation:ConnectParallel(UpdateCasts) + else + ParallelSimulation.Connection = + RS.Heartbeat:ConnectParallel(UpdateCasts) + end end end diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index f8b74dc8..b70ca762 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -6,228 +6,318 @@ --!native --!optimize 2 --- Services - local RS = game:GetService("RunService") -local TypeDef = require(script.Parent:WaitForChild("TypeDefinitions")) -local FastCastEnums = require(script.Parent:WaitForChild("FastCastEnums")) -local Config = require(script.Parent:WaitForChild("Config")) -local DebugLogging = Config.DebugLogging +local FastCastModule = script.Parent --- Constants +local TypeDef = require(FastCastModule:WaitForChild("TypeDefinitions")) +local FastCastEnums = require(FastCastModule:WaitForChild("FastCastEnums")) +local Config = require(FastCastModule:WaitForChild("Config")) + +local DebugLogging = Config.DebugLogging local EnumCastTypes = FastCastEnums.CastType + local DEFAULT_MAX_DISTANCE = 1000 local FC_VIS_OBJ_NAME = "FastCastVisualizationObjects" --- Variables +-- ============================================================================ +-- Types +-- ============================================================================ + +type BlockcastVariant = { + CastType: number, + Size: Vector3 +} + +type SpherecastVariant = { + CastType: number, + Radius: number +} + +type CastVariants = BlockcastVariant | SpherecastVariant + +type RayVisualizerVariant = { + castLength: number +} + +type BlockVisualizerVariant = { + size: Vector3 +} + +type SphereVisualizerVariant = { + radius: number +} + +type CastVisualizerVariants = + RayVisualizerVariant + | BlockVisualizerVariant + | SphereVisualizerVariant + +-- Event type constants (numeric IDs avoid string comparison in hot path) +local EVT_LENGTH_CHANGED = 1 +local EVT_HIT = 2 +local EVT_PIERCED = 3 +local EVT_CAST_TERMINATING = 4 + +--[[ + Shoutout to DrSinek for some code +]] +local EPSILON = 1e-6 +local RAY_SEARCH_OFFSET = 1e-4 + +type QueuedVisualizeCastData = { + castStartCFrame: CFrame, + sub: boolean +} + +type QueuedVisualizeHitData = { + atCF: CFrame, + wasPierce: boolean, + sub: boolean +} + +type QueuedVis = { any } + +-- ============================================================================ +-- State +-- ============================================================================ local casts_TotalRunTime = {} :: { [number]: number } local casts_DistanceCovered = {} :: { [number]: number } + local casts_HighFidelitySegmentSize = {} :: { [number]: number } local casts_HighFidelityBehavior = {} :: { [number]: number } + local casts_IsActivelySimulatingPierce = {} :: { [number]: boolean } local casts_IsActivelyResimulating = {} :: { [number]: boolean } local casts_CancelHighResCast = {} :: { [number]: boolean } + local casts_Trajectory = {} :: { [number]: TypeDef.CastTrajectory } -local casts_FastCastEventsConfig = {} :: { [number]: TypeDef.FastCastEventsConfig } + +local casts_FastCastEventsConfig = + {} :: { [number]: TypeDef.FastCastEventsConfig } + local casts_VisualizeCasts = {} :: { [number]: boolean } -local casts_VisualizeCastSettings = {} :: { [number]: TypeDef.VisualizeCastSettings } +local casts_VisualizeCastSettings = + {} :: { [number]: TypeDef.VisualizeCastSettings } + local casts_RayInfo = {} :: { [number]: TypeDef.CastRayInfo } local casts_UserData = {} :: { [number]: any } + local casts_CFrame = {} :: { [number]: CFrame } + local casts_CastType = {} :: { [number]: number } local casts_CastVariant = {} :: { [number]: CastVariants } + local casts_MaxDistance = {} :: { [number]: number } -local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } local casts_RayDisplacement = {} :: { [number]: number } --- Types +local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } -type BlockcastVariant = { CastType: number, Size: Vector3 } -type SpherecastVariant = { CastType: number, Radius: number } -type CastVariants = BlockcastVariant | SpherecastVariant +local casts_ID = {} :: { number } +local casts_ID_Index = {} :: { [number]: number } +-- Cached per-cast "does anyone care about this event?" flags. +local casts_WantLengthChanged = {} :: { [number]: boolean } +local casts_HasCosmetic = {} :: { [number]: boolean } +local castsWithCosmetics: number = 0 -type RayVisualizerVariant = { castLength: number} -type BlockVisualizerVariant = { size: Vector3 } -type SphereVisualizerVariant = { radius: number } -type CastVisualizerVariants = RayVisualizerVariant | BlockVisualizerVariant | SphereVisualizerVariant +-- Packed metadata: f64 cast ID + u8 event type. Keep IDs lossless (no u32 +-- wraparound); Roblox values stay in the reusable argument tables below. +-- Capacity doubles only at a new high-water mark, never during steady state. +local EVENT_STRIDE = 9 +local eventCapacity = 256 +local eventMetadata = buffer.create(eventCapacity * EVENT_STRIDE) +local queuedEventsCount: number = 0 -type QueuedEventData = { - eventType: string, - args: { any } -} +-- Flat visualization queue: { castID, data } +-- Reused every frame via table.clear +local queuedVisualizes: { QueuedVis } = {} +local queuedVisCount: number = 0 -type QueuedVisualizeCastData = { - castStartCFrame: CFrame, - sub: boolean -} +local ActivesRef: any = nil +local BaseCastRef: any = nil -type QueuedVisualizeHitData = { - atCF: CFrame, - wasPierce: boolean, - sub: boolean -} +local CurrentMovementMode: "BulkMoveTo" | "Motor6D" = "BulkMoveTo" +local MovementEnabled = false + +local FastCastEvents = {} :: any + +-- ============================================================================ +-- Math +-- ============================================================================ + +local function GetPositionAtTime( + t: number, + origin: Vector3, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + return origin + + initialVelocity * t + + acceleration * (t * t * 0.5) +end --- queued things +local function GetVelocityAtTime( + t: number, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + return initialVelocity + acceleration * t +end -local queuedEvents: { [number]: { QueuedEventData } } = {} -local queuedVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData }} = {} +-- ============================================================================ +-- Cast handlers +-- ============================================================================ local castHandlers = { [EnumCastTypes.Raycast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams ) - return targetWorldRoot:Raycast(origin, direction, params) + return worldRoot:Raycast(origin, direction, params) end, + [EnumCastTypes.Blockcast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams, variant: BlockcastVariant ) - return targetWorldRoot:Blockcast(CFrame.new(origin), variant.Size, direction, params) + return worldRoot:Blockcast( + CFrame.new(origin), + variant.Size, + direction, + params + ) end, + [EnumCastTypes.Spherecast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams, variant: SpherecastVariant ) - return targetWorldRoot:Spherecast(origin, variant.Radius, direction, params) + return worldRoot:Spherecast( + origin, + variant.Radius, + direction, + params + ) end } --- Utils - -local function GetPositionAtTime( - t: number, - origin: Vector3, - initialVelocity: Vector3, - acceleration: Vector3 -): Vector3 - local force = Vector3.new( - (acceleration.X * t ^ 2) / 2, - (acceleration.Y * t ^ 2) / 2, - (acceleration.Z * t ^ 2) / 2 - ) - return origin + (initialVelocity * t) + force -end - -local function GetVelocityAtTime(time: number, initialVelocity: Vector3, acceleration: Vector3): Vector3 - return initialVelocity + acceleration * time -end +-- ============================================================================ +-- Visualization +-- ============================================================================ -local function DebrisAdd(obj: Instance, Lifetime: number) - if not obj then - return - end - if Lifetime <= 0 then +local function DebrisAdd(obj: Instance, lifetime: number) + if lifetime <= 0 then obj:Destroy() return end - task.delay(Lifetime, function() - obj:Destroy() + task.delay(lifetime, function() + if obj.Parent then + obj:Destroy() + end end) end -local function GetFastCastVisualizationContainer(): Instance - local fcVisualizationObjects = workspace.Terrain:FindFirstChild(FC_VIS_OBJ_NAME) - if fcVisualizationObjects then - return fcVisualizationObjects +local function GetVisualizationContainer(): Instance + local container = workspace.Terrain:FindFirstChild(FC_VIS_OBJ_NAME) + + if container then + return container end - fcVisualizationObjects = Instance.new("Folder") - fcVisualizationObjects.Name = FC_VIS_OBJ_NAME - fcVisualizationObjects.Archivable = false - fcVisualizationObjects.Parent = workspace.Terrain - return fcVisualizationObjects + container = Instance.new("Folder") + container.Name = FC_VIS_OBJ_NAME + container.Archivable = false + container.Parent = workspace.Terrain + + return container end local function DbgVisualizeRaySegment( - castStartCFrame: CFrame, - VisualizeCastSettings: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: RayVisualizerVariant -): ConeHandleAdornment? +) local adornment = Instance.new("ConeHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame + adornment.CFrame = startCFrame adornment.Height = variant.castLength - adornment.Color3 = VisualizeCastSettings.Debug_SegmentColor - adornment.Radius = VisualizeCastSettings.Debug_SegmentSize - adornment.Transparency = VisualizeCastSettings.Debug_SegmentTransparency - adornment.Parent = GetFastCastVisualizationContainer() + adornment.Color3 = settings.Debug_SegmentColor + adornment.Radius = settings.Debug_SegmentSize + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - DebrisAdd(adornment, VisualizeCastSettings.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeBlockSegment( - castStartCFrame: CFrame, - VisualizeCastSetting: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: BlockVisualizerVariant -): BoxHandleAdornment? +) local adornment = Instance.new("BoxHandleAdornment") - adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame - --adornment.Height = castLength + adornment.Adornee = workspace.Terrain + adornment.CFrame = startCFrame adornment.Size = variant.size - adornment.Color3 = VisualizeCastSetting.Debug_SegmentColor - adornment.Transparency = VisualizeCastSetting.Debug_SegmentTransparency + adornment.Color3 = settings.Debug_SegmentColor + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - adornment.Parent = GetFastCastVisualizationContainer() - - DebrisAdd(adornment, VisualizeCastSetting.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeSphereSegment( - castStartCFrame: CFrame, - VisualizeCastSetting: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: SphereVisualizerVariant -): SphereHandleAdornment? +) local adornment = Instance.new("SphereHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame - --adornment.Height = castLength + adornment.CFrame = startCFrame adornment.Radius = variant.radius - --adornment.Size = Vector3.new(size.X, size.Y, size.Z + castLength) - adornment.Color3 = VisualizeCastSetting.Debug_SegmentColor - adornment.Transparency = VisualizeCastSetting.Debug_SegmentTransparency + adornment.Color3 = settings.Debug_SegmentColor + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - adornment.Parent = GetFastCastVisualizationContainer() - - DebrisAdd(adornment, VisualizeCastSetting.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeHit( - atCF: CFrame, + atCFrame: CFrame, wasPierce: boolean, - VisualizeCastSettings: TypeDef.VisualizeCastSettings -): SphereHandleAdornment? + settings: TypeDef.VisualizeCastSettings +) local adornment = Instance.new("SphereHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = atCF - -- Alert! someone is Mawining it!!!!! - adornment.Radius = (wasPierce == false) and VisualizeCastSettings.Debug_HitSize - or VisualizeCastSettings.Debug_RayPierceSize - adornment.Transparency = (wasPierce == false) and VisualizeCastSettings.Debug_HitTransparency - or VisualizeCastSettings.Debug_RayPierceTransparency - adornment.Color3 = (wasPierce == false) and VisualizeCastSettings.Debug_HitColor - or VisualizeCastSettings.Debug_RayPierceColor - adornment.Parent = GetFastCastVisualizationContainer() + adornment.CFrame = atCFrame + + if wasPierce then + adornment.Radius = settings.Debug_RayPierceSize + adornment.Transparency = settings.Debug_RayPierceTransparency + adornment.Color3 = settings.Debug_RayPierceColor + else + adornment.Radius = settings.Debug_HitSize + adornment.Transparency = settings.Debug_HitTransparency + adornment.Color3 = settings.Debug_HitColor + end + + adornment.Parent = GetVisualizationContainer() - DebrisAdd(adornment, VisualizeCastSettings.Debug_HitLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_HitLifetime) end local Visualizers = { @@ -236,276 +326,381 @@ local Visualizers = { [EnumCastTypes.Spherecast] = DbgVisualizeSphereSegment } --- SerialSimulation - -local SerialSimulation = {} -SerialSimulation.__index = SerialSimulation -SerialSimulation.__type = "SerialSimulation" +-- ============================================================================ +-- Queued visualization +-- ============================================================================ -function SerialSimulation:Register(cast: any) - local id = cast.ID +-- ============================================================================ +-- Pre-allocated pools (reused every frame, never freed) +-- ============================================================================ - casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 - casts_DistanceCovered[id] = 0 - casts_HighFidelitySegmentSize[id] = cast.StateInfo.HighFidelitySegmentSize or 0.1 - casts_HighFidelityBehavior[id] = cast.StateInfo.HighFidelityBehavior or 0 - casts_IsActivelySimulatingPierce[id] = false - casts_IsActivelyResimulating[id] = false - casts_CancelHighResCast[id] = false - casts_Trajectory[id] = cast.StateInfo.Trajectory - casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - casts_RayInfo[id] = cast.RayInfo - casts_UserData[id] = cast.UserData - casts_CastType[id] = cast.CastVariant.CastType - casts_CastVariant[id] = cast.CastVariant - casts_MaxDistance[id] = cast.RayInfo.MaxDistance or DEFAULT_MAX_DISTANCE - table.insert(self.casts_ID, id) - self.casts_ID_Index[id] = #self.casts_ID +local argsPool: { { any } } = {} +local argsPoolCount: number = 0 - local position = GetPositionAtTime( - casts_TotalRunTime[id], - casts_Trajectory[id].Origin, - casts_Trajectory[id].InitialVelocity, - casts_Trajectory[id].Acceleration - ) - casts_CFrame[id] = CFrame.new(position) +local function GetArgs(): { any } + argsPoolCount += 1 + if argsPoolCount > #argsPool then + argsPool[argsPoolCount] = {} + end + return argsPool[argsPoolCount] +end - cast.CFrame = casts_CFrame[id] +local visPool: { QueuedVis } = {} +local visPoolCount: number = 0 - if self.CurrentMovementMode == "Motor6D" and self.MovementEnabled then - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and self.BaseCastRef and self.BaseCastRef._GetMotor6D then - local motor6d = self.BaseCastRef:_GetMotor6D(cosmeticPart) - casts_ActiveMotor6Ds[id] = motor6d - end +local function GetVis(): QueuedVis + visPoolCount += 1 + if visPoolCount > #visPool then + visPool[visPoolCount] = { 0, nil } end - - queuedEvents[id] = {} + return visPool[visPoolCount] end -function SerialSimulation:Unregister(castID: number) - casts_TotalRunTime[castID] = nil - casts_DistanceCovered[castID] = nil - casts_HighFidelitySegmentSize[castID] = nil - casts_HighFidelityBehavior[castID] = nil - casts_IsActivelySimulatingPierce[castID] = nil - casts_IsActivelyResimulating[castID] = nil - casts_CancelHighResCast[castID] = nil - casts_Trajectory[castID] = nil - casts_FastCastEventsConfig[castID] = nil - casts_VisualizeCasts[castID] = nil - casts_VisualizeCastSettings[castID] = nil - casts_RayInfo[castID] = nil - casts_UserData[castID] = nil - casts_CFrame[castID] = nil - casts_CastType[castID] = nil - casts_CastVariant[castID] = nil - casts_RayDisplacement[castID] = nil +-- Pooled visualization data tables (avoids per-frame allocations) +-- Hit/pierce data layout: { atCF :: CFrame, wasPierce :: boolean, sub :: boolean } +-- Segment data layout: { castStartCFrame :: CFrame, sub :: boolean } +local hitVisPool: { { any } } = {} +local hitVisPoolCount: number = 0 - if casts_ActiveMotor6Ds[castID] then - if self.BaseCastRef and self.BaseCastRef._ReturnMotor6D then - self.BaseCastRef:_ReturnMotor6D(casts_ActiveMotor6Ds[castID]) - end - casts_ActiveMotor6Ds[castID] = nil +local segVisPool: { { any } } = {} +local segVisPoolCount: number = 0 + +--[[ + Do not remove ":: any" it giving me annoying warning +]] + +local function GetHitVisData(): { any } + hitVisPoolCount += 1 + if hitVisPoolCount > #hitVisPool then + hitVisPool[hitVisPoolCount] = { nil :: any, false, false } end + return hitVisPool[hitVisPoolCount] +end - local idx = self.casts_ID_Index[castID] - if idx then - local lastID = self.casts_ID[#self.casts_ID] - self.casts_ID[idx] = lastID - self.casts_ID_Index[lastID] = idx - self.casts_ID[#self.casts_ID] = nil - self.casts_ID_Index[castID] = nil +local function GetSegVisData(): { any } + segVisPoolCount += 1 + if segVisPoolCount > #segVisPool then + segVisPool[segVisPoolCount] = { nil :: any, false } end + return segVisPool[segVisPoolCount] +end - queuedEvents[castID] = nil - queuedVisualizes[castID] = nil +local function ResetPools() + argsPoolCount = 0 + visPoolCount = 0 + hitVisPoolCount = 0 + segVisPoolCount = 0 end -function SerialSimulation:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") - local oldMode = self.CurrentMovementMode - self.CurrentMovementMode = mode - self.MovementEnabled = enabled +-- ============================================================================ +-- Queued visualizations (flat, allocation-free) +-- ============================================================================ - if oldMode == "Motor6D" and mode ~= "Motor6D" then - for id, motor6d in casts_ActiveMotor6Ds do - if self.BaseCastRef and self.BaseCastRef._ReturnMotor6D then - self.BaseCastRef:_ReturnMotor6D(motor6d) - end - casts_ActiveMotor6Ds[id] = nil - end +-- Hit/pierce visualization: pass fields directly, data table is pooled +local function QueueVisualizeHit( + castID: number, + atCF: CFrame, + wasPierce: boolean, + sub: boolean +) + if not casts_VisualizeCasts[castID] then + return end - if mode == "Motor6D" and enabled and oldMode ~= "Motor6D" then - for _, id in self.casts_ID do - if not casts_ActiveMotor6Ds[id] then - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and self.BaseCastRef and self.BaseCastRef._GetMotor6D then - local motor6d = self.BaseCastRef:_GetMotor6D(cosmeticPart) - casts_ActiveMotor6Ds[id] = motor6d - end - end - end - end + local data = GetHitVisData() + data[1] = atCF + data[2] = wasPierce + data[3] = sub + + queuedVisCount += 1 + local entry = GetVis() + entry[1] = castID + entry[2] = data + entry[3] = 1 -- tag: 1 = hit data + queuedVisualizes[queuedVisCount] = entry end -function SerialSimulation:QueueVisualize(castID: number, visualizeData: QueuedVisualizeCastData | QueuedVisualizeHitData) +-- Segment visualization: pass fields directly, data table is pooled +local function QueueVisualizeSegment( + castID: number, + castStartCFrame: CFrame, + sub: boolean +) if not casts_VisualizeCasts[castID] then return end - if not queuedVisualizes[castID] then - queuedVisualizes[castID] = {} - end + local data = GetSegVisData() + data[1] = castStartCFrame + data[2] = sub - table.insert(queuedVisualizes[castID], visualizeData) + queuedVisCount += 1 + local entry = GetVis() + entry[1] = castID + entry[2] = data + entry[3] = 2 -- tag: 2 = segment data + queuedVisualizes[queuedVisCount] = entry end -function SerialSimulation:FireQueuedVisualizes(unFiredVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData }}) - local sortedIDs = {} - for id in unFiredVisualizes do - table.insert(sortedIDs, id) - end - table.sort(sortedIDs) +-- Reusable variant table (consumed synchronously, safe to reuse) +local reusableVariant = {} :: CastVisualizerVariants - for _, castID in sortedIDs do - local visualizesList = unFiredVisualizes[castID] - if not visualizesList or not next(visualizesList) then +local function FireQueuedVisualizes() + for i = 1, queuedVisCount do + local entry = queuedVisualizes[i] + local castID = entry[1] :: number + local data = entry[2] + local dataTag = entry[3] :: number + + local settings = casts_VisualizeCastSettings[castID] + if not settings then continue end - for _, visualizeData in visualizesList do - -- VisualizeHit - if visualizeData.atCF then - if DebugLogging.Hit and not visualizeData.wasPierce and not visualizeData.sub then - print("Hit was successful. Terminating.") - end - if DebugLogging.Pierced and visualizeData.wasPierce and not visualizeData.sub then - print("Piercing function returned TRUE to pierce this part.") - end - DbgVisualizeHit(visualizeData.atCF, visualizeData.wasPierce, casts_VisualizeCastSettings[castID]) - continue - end - -- VisualizeCasts - if DebugLogging.Segment and visualizeData.sub then - print("Subcast visualization") - end - local castType = casts_CastType[castID] - local castVisualizer = Visualizers[castType] - if not castVisualizer then - continue + -- Hit visualization (tag 1) + if dataTag == 1 then + local atCF = data[1] :: CFrame + local wasPierce = data[2] :: boolean + local sub = data[3] :: boolean + + if DebugLogging.Hit + and not wasPierce + and not sub + then + print("Hit was successful. Terminating.") end - local visualizerVariant: CastVisualizerVariants - if castType == EnumCastTypes.Raycast then - visualizerVariant = { castLength = casts_RayDisplacement[castID] or 1 } - elseif castType == EnumCastTypes.Blockcast then - visualizerVariant = { size = (casts_CastVariant[castID] :: BlockcastVariant).Size } - else - visualizerVariant = { radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius } + + if DebugLogging.Pierced + and wasPierce + and not sub + then + print("Piercing function returned TRUE to pierce this part.") end - castVisualizer(visualizeData.castStartCFrame, casts_VisualizeCastSettings[castID], visualizerVariant) + + DbgVisualizeHit( + atCF, + wasPierce, + settings + ) + + continue + end + + -- Segment visualization (tag 2) + local castStartCFrame = data[1] :: CFrame + local sub = data[2] :: boolean + + if DebugLogging.Segment and sub then + print("Subcast visualization") + end + + local castType = casts_CastType[castID] + local visualizer = Visualizers[castType] + + if not visualizer then + continue + end + + -- Reuse single variant table (consumed synchronously) + if castType == EnumCastTypes.Raycast then + reusableVariant.castLength = casts_RayDisplacement[castID] or 1 + elseif castType == EnumCastTypes.Blockcast then + reusableVariant.size = (casts_CastVariant[castID] :: BlockcastVariant).Size + else + reusableVariant.radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius end - end -end -function SerialSimulation:QueueEvent(castID: number, eventType: string, ...: any) - local args = { ... } - if not queuedEvents[castID] then - queuedEvents[castID] = {} + visualizer( + castStartCFrame, + settings, + reusableVariant + ) end - table.insert(queuedEvents[castID], { - eventType = eventType, - args = args - }) end -function SerialSimulation:FireQueuedEvents(unFiredEvents: { [number]: { QueuedEventData } }) - local sortedIDs = {} - for id in unFiredEvents do - table.insert(sortedIDs, id) +-- ============================================================================ +-- Queued events (flat, allocation-free) +-- ============================================================================ + +-- Event signatures have at most five payload values. Assign all slots so +-- nil holes cannot leave stale references behind (the # operator is undefined +-- for sparse arrays), and avoid select/vararg loops in the hot path. +local function QueueEvent( + castID: number, + eventType: number, + arg1: any, + arg2: any, + arg3: any, + arg4: any, + arg5: any +) + local args = GetArgs() + args[1] = arg1 + args[2] = arg2 + args[3] = arg3 + args[4] = arg4 + args[5] = arg5 + + queuedEventsCount += 1 + if queuedEventsCount > eventCapacity then + eventCapacity *= 2 + local expanded = buffer.create(eventCapacity * EVENT_STRIDE) + buffer.copy(expanded, 0, eventMetadata) + eventMetadata = expanded end - table.sort(sortedIDs) + local offset = (queuedEventsCount - 1) * EVENT_STRIDE + buffer.writef64(eventMetadata, offset, castID) + buffer.writeu8(eventMetadata, offset + 8, eventType) +end - local eventsFunction = self.Events +local function FireQueuedEvents() + for i = 1, queuedEventsCount do + local offset = (i - 1) * EVENT_STRIDE + local castID = buffer.readf64(eventMetadata, offset) + local eventType = buffer.readu8(eventMetadata, offset + 8) + local args = argsPool[i] - for _, castID in sortedIDs do - local eventList = unFiredEvents[castID] - if not eventList or not next(eventList) then + local cast = ActivesRef[castID] + if not cast then continue end - for _, event in eventList do - local cast = self.ActivesRef[castID] - if not cast then - continue - end - - local eventType: string = event.eventType - local args: { any } = event.args + local eventConfig = casts_FastCastEventsConfig[castID] - local eventConfig = casts_FastCastEventsConfig[castID] - - if eventType == "LengthChanged" then - local lastPoint = args[1] - local rayDir = args[2] - local rayDisplacement = args[3] - local segmentVelocity, CosmeticBulletObject = args[4], args[5] + if not eventConfig then + return + end - if eventConfig and eventConfig.UseLengthChanged and eventsFunction.LengthChanged then - eventsFunction.LengthChanged(cast, lastPoint, rayDir, rayDisplacement, segmentVelocity, CosmeticBulletObject) + if eventType == EVT_LENGTH_CHANGED then + if eventConfig.UseLengthChanged then + local lengthChanged = FastCastEvents.LengthChanged + if lengthChanged then + lengthChanged:Fire( + cast, + args[1], + args[2], + args[3], + args[4], + args[5] + ) end + end - elseif eventType == "Hit" then - local result = args[1] - local velocity = args[2] - local cosmeticBulletObject = args[3] + elseif eventType == EVT_HIT then + if eventConfig.UseHit then + local Hit = FastCastEvents.Hit + if Hit then + Hit:Fire( + cast, + args[1], + args[2], + args[3] + ) + end + end - if eventConfig and eventConfig.UseHit and eventsFunction.Hit then - eventsFunction.Hit(cast, result, velocity, cosmeticBulletObject) + elseif eventType == EVT_PIERCED then + if eventConfig.UsePierced then + local Pierced = FastCastEvents.Pierced + if Pierced then + Pierced:Fire( + cast, + args[1], + args[2], + args[3] + ) end + end - elseif eventType == "Pierced" then - local result = args[1] - local velocity = args[2] - local cosmeticBulletObject = args[3] + elseif eventType == EVT_CAST_TERMINATING then + if not cast._Destroying or cast._TerminationQueued then + cast._Destroying = true + cast._TerminationQueued = false - if eventConfig and eventConfig.UsePierced and eventsFunction.Pierced then - eventsFunction.Pierced(cast, result, velocity, cosmeticBulletObject) + local castTerminating = FastCastEvents.CastTerminating + if eventConfig.UseCastTerminating and castTerminating then + castTerminating:Fire(cast) end - - elseif eventType == "CastTerminating" then - self.BaseCastRef:_TerminateCast(castID, eventsFunction.CastTerminating) - self:Unregister(castID) end + + BaseCastRef._TerminateCast(castID) end end + + ResetPools() + queuedEventsCount = 0 + queuedVisCount = 0 +end + +-- ============================================================================ +-- Movement +-- ============================================================================ + +-- Pre-allocated arrays for BulkMoveTo (reused every frame) +local bulkParts: { BasePart } = {} +local bulkCframes: { CFrame } = {} +local bulkCount: number = 0 +local cosmeticFrame: number = 0 + +-- Cosmetic parts can be pushed at a lower rate than the simulation. Moving +-- thousands of parts (and replicating their CFrames) is the single most +-- expensive per-frame cost once cosmetics exist. +local function ShouldUpdateCosmetics(): boolean + local interval = Config.CosmeticUpdateInterval or 1 + if interval <= 1 then + return true + end + + cosmeticFrame += 1 + return cosmeticFrame % interval == 0 end -function SerialSimulation:BulkMoveTo() - if self.CurrentMovementMode ~= "BulkMoveTo" or not self.MovementEnabled then +local function BulkMoveTo() + if CurrentMovementMode ~= "BulkMoveTo" + or not MovementEnabled + then return end - local parts = table.create(#self.casts_ID) - local cframes = table.create(#self.casts_ID) + -- Nothing to move if no cast has a cosmetic part. + if castsWithCosmetics == 0 then + return + end + + if not ShouldUpdateCosmetics() then + return + end + + bulkCount = 0 - for _, id in self.casts_ID do - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and cosmeticPart.Parent then - table.insert(parts, cosmeticPart) - table.insert(cframes, casts_CFrame[id]) + for _, id in casts_ID do + local rayInfo = casts_RayInfo[id] + local cosmetic = rayInfo and rayInfo.CosmeticBulletObject + + if cosmetic and cosmetic.Parent then + bulkCount += 1 + bulkParts[bulkCount] = cosmetic + bulkCframes[bulkCount] = casts_CFrame[id] end end - if #parts > 0 then - workspace:BulkMoveTo(parts, cframes, Enum.BulkMoveMode.FireCFrameChanged) + if bulkCount > 0 then + workspace:BulkMoveTo( + bulkParts, + bulkCframes, + Enum.BulkMoveMode.FireCFrameChanged + ) end end -function SerialSimulation:UpdateMotor6Ds() - if self.CurrentMovementMode ~= "Motor6D" or not self.MovementEnabled then +local function UpdateMotor6Ds() + if CurrentMovementMode ~= "Motor6D" + or not MovementEnabled + then + return + end + + if not ShouldUpdateCosmetics() then return end @@ -516,327 +711,782 @@ function SerialSimulation:UpdateMotor6Ds() end end --- RS -function SerialSimulation:SimulateCast( +-- ============================================================================ +-- Simulation helpers +-- ============================================================================ + +local function QueueHit( id: number, - delta: number, - CanPiercefn: TypeDef.CanPierceFunction? + result: RaycastResult, + velocity: Vector3, + sub: boolean, + castTerminatingfn: TypeDef.OnCastTerminatingFunction? ) - if DebugLogging.Casting then - print("Casting for frame - SimulateCast") - end - local trajectory = casts_Trajectory[id] + QueueEvent( + id, + EVT_HIT, + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - local origin = trajectory.Origin - local totalDelta = casts_TotalRunTime[id] - trajectory.StartTime - local initialVelocity = trajectory.InitialVelocity - local acceleration = trajectory.Acceleration + QueueVisualizeHit( + id, + CFrame.new(result.Position), + false, + sub + ) - local lastPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) + QueueEvent( + id, + EVT_CAST_TERMINATING, + castTerminatingfn + ) +end - local lastDelta = casts_TotalRunTime[id] - trajectory.StartTime - - casts_TotalRunTime[id] += delta +local function QueuePierce( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) + QueueEvent( + id, + EVT_PIERCED, + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - totalDelta = casts_TotalRunTime[id] - trajectory.StartTime + QueueVisualizeHit( + id, + CFrame.new(result.Position), + true, + sub + ) +end - local currentTarget = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) - local segmentVelocity = GetVelocityAtTime(totalDelta, initialVelocity, acceleration) - local totalDisplacement = currentTarget - lastPoint +local function ShouldPierce( + id: number, + result: RaycastResult, + velocity: Vector3, + canPierce: TypeDef.CanPierceFunction? +): boolean + if not canPierce then + return false + end - local rayDir = totalDisplacement + return canPierce( + ActivesRef[id], + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) +end - local targetWorldRoot = casts_RayInfo[id].WorldRoot +-- ============================================================================ +-- Automatic high-fidelity resimulation +-- ============================================================================ - local castHandler = castHandlers[casts_CastVariant[id].CastType] - local resultOfCast = castHandler(targetWorldRoot, lastPoint, rayDir, casts_RayInfo[id].Parameters, casts_CastVariant[id]) +local function ResimulateHit( + id: number, + delta: number, + lastDelta: number, + origin: Vector3, + initialVelocity: Vector3, + acceleration: Vector3, + targetWorldRoot: WorldRoot, + castHandler: any, + canPierce: TypeDef.CanPierceFunction?, + castTerminatingfn: TypeDef.OnCastTerminatingFunction?, + fallbackResult: RaycastResult, + fallbackVelocity: Vector3 +): boolean + + casts_IsActivelyResimulating[id] = true + + local rayDisplacement = casts_RayDisplacement[id] + + local segmentCount = math.max( + 1, + math.floor( + rayDisplacement + / casts_HighFidelitySegmentSize[id] + ) + ) - local point = currentTarget - local part: Instance? = nil + local timeIncrement = delta / segmentCount - if resultOfCast ~= nil then - point = resultOfCast.Position - part = resultOfCast.Instance + if DebugLogging.Calculation then + print( + "Performing subcast! Time increment: " + .. timeIncrement + .. ", num segments: " + .. segmentCount + ) end - local rayDisplacement = (point - lastPoint).Magnitude - casts_RayDisplacement[id] = rayDisplacement + local lastPierced = false - casts_CFrame[id] = (if rayDir ~= Vector3.new() then CFrame.new(lastPoint, lastPoint + rayDir) else CFrame.new(lastPoint)) * CFrame.new(0, 0, -rayDisplacement / 2) + for segmentIndex = 1, segmentCount do + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break + end - self:QueueEvent(id, "LengthChanged", lastPoint, rayDir.Unit, rayDisplacement, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) + local subTime = + lastDelta + timeIncrement * segmentIndex - casts_DistanceCovered[id] += rayDisplacement + local subPosition = GetPositionAtTime( + subTime, + origin, + initialVelocity, + acceleration + ) + + local subVelocity = GetVelocityAtTime( + subTime, + initialVelocity, + acceleration + ) + + local subDirection = subVelocity * timeIncrement + + -- Only offset after the previous subcast pierced + local subOrigin = if lastPierced + then subPosition + subVelocity.Unit * RAY_SEARCH_OFFSET + else subPosition + + lastPierced = false + + local subResult = castHandler( + targetWorldRoot, + subOrigin, + subDirection, + casts_RayInfo[id].Parameters, + casts_CastVariant[id] + ) + + if casts_VisualizeCasts[id] then + QueueVisualizeSegment( + id, + CFrame.new( + subPosition, + subPosition + subVelocity + ), + true + ) + end + + if not subResult then + continue + end + + if ShouldPierce( + id, + subResult, + subVelocity, + canPierce + ) then + QueuePierce( + id, + subResult, + subVelocity, + true + ) + + lastPierced = true + continue + end + + casts_IsActivelyResimulating[id] = false - if delta > 0 then - self:QueueVisualize( + QueueHit( id, - { - castStartCFrame = CFrame.new(lastPoint, lastPoint+rayDir), - sub = false - } :: any + subResult, + subVelocity, + true, + castTerminatingfn ) + + return true end - -- NOTE: Please dont remove "part and" - -- Why? basically when part doesn't exist it will do nothing, but removing "part and" will break the logic - -- You can't do anything about it - if part and part ~= casts_RayInfo[id].CosmeticBulletObject then - if - CanPiercefn == nil - or CanPiercefn(self.ActivesRef[id], resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) == false - then + casts_IsActivelyResimulating[id] = false - casts_IsActivelyResimulating[id] = false + -- The original cast hit, but none of the + -- high-resolution subcasts did. + QueueHit( + id, + fallbackResult, + fallbackVelocity, + false, + castTerminatingfn + ) - if - casts_HighFidelityBehavior[id] == FastCastEnums.HighFidelityBehavior.Automatic - and casts_HighFidelitySegmentSize[id] > 0 - then - casts_CancelHighResCast[id] = false + return true +end - if casts_IsActivelyResimulating[id] then - self:QueueEvent(id, "CastTerminating") +-- ============================================================================ +-- Single simulation step +-- ============================================================================ - warn( - "Cascading cast lag encountered! The caster attempted to perform a high fidelity cast before the previous one completed, resulting in exponential cast lag. Consider increasing HighFidelitySegmentSize." - ) - return - end +local function SimulateCast( + id: number, + delta: number +) + if DebugLogging.Casting then + print("Casting for frame - SimulateCast") + end + + local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] + + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration - casts_IsActivelyResimulating[id] = true + local previousTime = casts_TotalRunTime[id] - trajectory.StartTime - local numSegmentsDecimal = rayDisplacement / casts_HighFidelitySegmentSize[id] - local numSegmentsReal = math.floor(numSegmentsDecimal) - --local realSegmentLength = rayDisplacement / numSegmentsReal + local lastPoint = GetPositionAtTime( + previousTime, + origin, + initialVelocity, + acceleration + ) - if numSegmentsReal == 0 then - numSegmentsReal = 1 - end + casts_TotalRunTime[id] += delta - local timeIncrement = delta / numSegmentsReal - local subHitFound = false + local currentTime = casts_TotalRunTime[id] - trajectory.StartTime - for segmentIndex = 1, numSegmentsReal do - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end + local velocity = GetVelocityAtTime( + currentTime, + initialVelocity, + acceleration + ) - local subPosition = GetPositionAtTime( - lastDelta + (timeIncrement * segmentIndex), - origin, - initialVelocity, - acceleration - ) - local subVelocity = GetVelocityAtTime( - lastDelta + (timeIncrement * segmentIndex), - initialVelocity, - acceleration - ) - local subRayDir = subVelocity * timeIncrement - local subResult = castHandler( - targetWorldRoot, - subPosition, - subRayDir, - casts_RayInfo[id].Parameters, - casts_CastVariant[id] - ) + -- p(t1)-p(t0) = (v0 + a*(t0+t1)/2)*(t1-t0). + -- Avoid a second position evaluation and cancellation of large world + -- coordinates. Use the actual elapsed interval after runtime rounding. + local displacement = (initialVelocity + acceleration * ((previousTime + currentTime) * 0.5)) + * (currentTime - previousTime) + local displacementMagnitude = displacement.Magnitude - --local subDisplacement = (subPosition - (subPosition + subVelocity)).Magnitude - - if subResult ~= nil then - subHitFound = true - - self:QueueVisualize(id, { - castStartCFrame = CFrame.new(subPosition, subPosition + subVelocity), - sub = true - } :: any) - - --subDispalcement = (subPosition - subResult.Position).Magnitude - - if - CanPiercefn == nil - or CanPiercefn(self.ActivesRef[id], subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) == false - then - casts_IsActivelyResimulating[id] = false - self:QueueEvent(id, "Hit", subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueEvent(id, "CastTerminating") - self:QueueVisualize(id, { - atCF = CFrame.new(subResult.Position), - wasPierce = false, - sub = true - }:: any) - - return - else - self:QueueEvent(id, "Pierced", subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(subResult.Position), - wasPierce = true, - sub = true - }:: any) - end - else - self:QueueVisualize(id, { - castStartCFrame = CFrame.new(subPosition, subPosition+subVelocity), - sub = true - }:: any) - end - end - casts_IsActivelyResimulating[id] = false - if not subHitFound then - self:QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - }:: any) - self:QueueEvent(id, "CastTerminating") - return - end - else - self:QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - }:: any) - self:QueueEvent(id, "CastTerminating") - - return - end - else - self:QueueEvent(id, "Pierced", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = true - }:: any) + -- Skip raycast when displacement is negligible (matches SwiftCast EPSILON) + if displacementMagnitude <= EPSILON then + casts_DistanceCovered[id] += displacementMagnitude + return + end + + local worldRoot = rayInfo.WorldRoot + local handler = castHandlers[variant.CastType] + + local result = handler( + worldRoot, + lastPoint, + displacement, + rayInfo.Parameters, + variant + ) + + local rayDisplacement = if result + then (result.Position - lastPoint).Magnitude + else displacementMagnitude + + casts_RayDisplacement[id] = rayDisplacement + + -- Build the midpoint transform directly instead of multiplying two + -- CFrames. lookAlong avoids losing direction when origin + direction + -- rounds back to origin at large world coordinates. + local unitDirection = displacement / displacementMagnitude + casts_CFrame[id] = CFrame.lookAlong( + lastPoint + unitDirection * (rayDisplacement * 0.5), + unitDirection + ) + + if casts_WantLengthChanged[id] and FastCastEvents.LengthChanged then + QueueEvent( + id, + EVT_LENGTH_CHANGED, + lastPoint, + unitDirection, + rayDisplacement, + velocity, + rayInfo.CosmeticBulletObject + ) + end + + casts_DistanceCovered[id] += rayDisplacement + + if delta > 0 and casts_VisualizeCasts[id] then + QueueVisualizeSegment( + id, + CFrame.lookAlong(lastPoint, unitDirection), + false + ) + end + + -- ------------------------------------------------------------------------ + -- No hit. + -- ------------------------------------------------------------------------ + + if not result then + if casts_DistanceCovered[id] >= casts_MaxDistance[id] then + QueueEvent( + id, + EVT_CAST_TERMINATING + ) end + + return end - if casts_DistanceCovered[id] >= casts_MaxDistance[id] then - if resultOfCast then - self:QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - }:: any) + -- ------------------------------------------------------------------------ + -- Cosmetic bullet collision. + -- ------------------------------------------------------------------------ + + if result.Instance == rayInfo.CosmeticBulletObject then + return + end + + local canPiercefn = FastCastEvents.CanPierce + local castTerminatingfn = FastCastEvents.CastTerminating + + -- ------------------------------------------------------------------------ + -- Pierce. + -- ------------------------------------------------------------------------ + + if ShouldPierce( + id, + result, + velocity, + canPiercefn + ) then + QueuePierce( + id, + result, + velocity, + false + ) + + return + end + + -- ------------------------------------------------------------------------ + -- Hit. + -- ------------------------------------------------------------------------ + + if casts_HighFidelityBehavior[id] + == FastCastEnums.HighFidelityBehavior.Automatic + and casts_HighFidelitySegmentSize[id] > 0 + then + if casts_IsActivelyResimulating[id] then + QueueEvent( + id, + EVT_CAST_TERMINATING, + castTerminatingfn + ) + + warn( + "Cascading cast lag encountered! " + .. "The caster attempted to perform a high fidelity " + .. "cast before the previous one completed. " + .. "Consider increasing HighFidelitySegmentSize." + ) + + return end - self:QueueEvent(id, "CastTerminating") + + local lastDelta = previousTime + + ResimulateHit( + id, + delta, + lastDelta, + origin, + initialVelocity, + acceleration, + worldRoot, + handler, + canPiercefn, + castTerminatingfn, + result, + velocity + ) + + return end + + QueueHit( + id, + result, + velocity, + false + ) end -function SerialSimulation:UpdateCasts(delta: number) - for _, id in self.casts_ID do - if DebugLogging.Casting then - print("Casting for frame - UpdateCasts") - end +-- ============================================================================ +-- Always high-fidelity simulation +-- ============================================================================ - if self.ActivesRef[id] and self.ActivesRef[id]._Destroying then - continue +local function SimulateAlwaysHighFidelity( + id: number, + delta: number +) + if casts_IsActivelyResimulating[id] then + QueueEvent( + id, + EVT_CAST_TERMINATING + ) + + warn( + "Cascading cast lag encountered! " + .. "The caster attempted to perform a high fidelity " + .. "cast before the previous one completed. " + .. "Consider increasing HighFidelitySegmentSize." + ) + + return + end + + casts_IsActivelyResimulating[id] = true + + local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] + + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration + + local currentTime = casts_TotalRunTime[id] - trajectory.StartTime + + local lastPoint = GetPositionAtTime( + currentTime, + origin, + initialVelocity, + acceleration + ) + + -- Temporarily advance time to determine + -- the approximate distance for this frame. + casts_TotalRunTime[id] += delta + + local nextTime = casts_TotalRunTime[id] - trajectory.StartTime + + local currentPoint = GetPositionAtTime( + nextTime, + origin, + initialVelocity, + acceleration + ) + + local currentVelocity = GetVelocityAtTime( + nextTime, + initialVelocity, + acceleration + ) + + local totalDisplacement = currentPoint - lastPoint + + local direction = + totalDisplacement.Unit + * currentVelocity.Magnitude + * delta + + local handler = castHandlers[variant.CastType] + + local resultOfCast = handler( + rayInfo.WorldRoot, + lastPoint, + direction, + rayInfo.Parameters, + variant + ) + + local endpoint = + resultOfCast + and resultOfCast.Position + or currentPoint + + local distance = (endpoint - lastPoint).Magnitude + + casts_TotalRunTime[id] -= delta + + local segmentCount = math.max( + 1, + math.floor( + distance + / casts_HighFidelitySegmentSize[id] + ) + ) + + local timeIncrement = delta / segmentCount + + if DebugLogging.Calculation then + print( + "Performing subcast! Time increment: " + .. timeIncrement + .. ", num segments: " + .. segmentCount + ) + end + + for segmentIndex = 1, segmentCount do + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break end - local Trajectory: TypeDef.CastTrajectory = casts_Trajectory[id] + if not ActivesRef[id] then + casts_IsActivelyResimulating[id] = false + return + end - if casts_HighFidelitySegmentSize[id] <= 0 then - casts_HighFidelitySegmentSize[id] = 0.1 + if DebugLogging.Segment then + print( + "[" + .. segmentIndex + .. "] Subcast of time increment " + .. timeIncrement + ) end - if casts_HighFidelityBehavior[id] == FastCastEnums.HighFidelityBehavior.Always then + SimulateCast( + id, + timeIncrement + ) + end - if casts_IsActivelyResimulating[id] then - self:QueueEvent(id, "CastTerminating") - warn("Casading cast lag encountered! The caster attempted to perform a high fidelity cast before the previous one completed, resulting in exponential cast lag. Consider increasing HighFidelitySegmentSize.") - continue - end - casts_IsActivelyResimulating[id] = true + casts_IsActivelyResimulating[id] = false +end - local origin = Trajectory.Origin - local totalDelta = casts_TotalRunTime[id] - Trajectory.StartTime - local initialVelocity = Trajectory.InitialVelocity - local acceleration = Trajectory.Acceleration +-- ============================================================================ +-- Registration +-- ============================================================================ - local lastPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) +function SerialSimulation_Register(cast: any) + local id = cast.ID - casts_TotalRunTime[id] += delta + casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 - totalDelta = casts_TotalRunTime[id] - Trajectory.StartTime + casts_DistanceCovered[id] = 0 - local currentPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) - local currentVelocity = GetVelocityAtTime(totalDelta, initialVelocity, acceleration) - local totalDisplacement = currentPoint - lastPoint + casts_HighFidelitySegmentSize[id] = cast.StateInfo.HighFidelitySegmentSize or 0.1 - local rayDir = totalDisplacement.Unit * currentVelocity.Magnitude * delta + casts_HighFidelityBehavior[id] = cast.StateInfo.HighFidelityBehavior or 0 - local RayInfo = casts_RayInfo[id] - local targetWorldRoot = RayInfo.WorldRoot + casts_IsActivelySimulatingPierce[id] = false + casts_IsActivelyResimulating[id] = false + casts_CancelHighResCast[id] = false - local castHandler = castHandlers[casts_CastVariant[id].CastType] - local resultOfCast = castHandler(targetWorldRoot, lastPoint, rayDir, RayInfo.Parameters, casts_CastVariant[id]) + casts_Trajectory[id] = cast.StateInfo.Trajectory - local point = currentPoint - if resultOfCast ~= nil then - point = resultOfCast.Position - end + casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - local rayDisplacement = (point - lastPoint).Magnitude - casts_TotalRunTime[id] -= delta + casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - local numSegmentsDecimal = rayDisplacement / casts_HighFidelitySegmentSize[id] - local numSegmentsReal = math.floor(numSegmentsDecimal) - if numSegmentsReal == 0 then - numSegmentsReal = 1 - end + casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - local timeIncrement = delta / numSegmentsReal + casts_RayInfo[id] = cast.RayInfo + casts_UserData[id] = cast.UserData - if DebugLogging.Calculation then - print("Performing subcast! Time increment: " .. timeIncrement .. ", num segments: " .. numSegmentsReal) - end + casts_CastType[id] = + cast.CastVariant.CastType - local cast_nil = false - for segmentIndex = 1, numSegmentsReal do + casts_CastVariant[id] = + cast.CastVariant - -- In case when cast Destroyed or not exist - if self.ActivesRef[id] == nil then - cast_nil = true - end + casts_MaxDistance[id] = + cast.RayInfo.MaxDistance + or DEFAULT_MAX_DISTANCE + table.insert(casts_ID, id) + casts_ID_Index[id] = #casts_ID - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end + local eventConfig = casts_FastCastEventsConfig[id] - if DebugLogging.Segment then - print("[" .. segmentIndex .. "] Subcast of time increment " .. timeIncrement) - end + casts_WantLengthChanged[id] = + eventConfig ~= nil and eventConfig.UseLengthChanged == true + + local hasCosmetic = cast.RayInfo.CosmeticBulletObject ~= nil + casts_HasCosmetic[id] = hasCosmetic + if hasCosmetic then + castsWithCosmetics += 1 + end + + local trajectory = casts_Trajectory[id] + + local position = GetPositionAtTime( + casts_TotalRunTime[id], + trajectory.Origin, + trajectory.InitialVelocity, + trajectory.Acceleration + ) + + casts_CFrame[id] = CFrame.new(position) + cast.CFrame = casts_CFrame[id] + + if CurrentMovementMode == "Motor6D" + and MovementEnabled + then + local cosmetic = casts_RayInfo[id].CosmeticBulletObject + + if cosmetic + and BaseCastRef + and BaseCastRef._GetMotor6D + then + casts_ActiveMotor6Ds[id] = + BaseCastRef._GetMotor6D(cosmetic) + end + end +end + +function SerialSimulation_Unregister(castID: number) + casts_TotalRunTime[castID] = nil + casts_DistanceCovered[castID] = nil + + casts_HighFidelitySegmentSize[castID] = nil + casts_HighFidelityBehavior[castID] = nil + + casts_IsActivelySimulatingPierce[castID] = nil + casts_IsActivelyResimulating[castID] = nil + casts_CancelHighResCast[castID] = nil + + casts_Trajectory[castID] = nil + + casts_FastCastEventsConfig[castID] = nil + + casts_WantLengthChanged[castID] = nil + if casts_HasCosmetic[castID] then + castsWithCosmetics -= 1 + end + casts_HasCosmetic[castID] = nil + + casts_VisualizeCasts[castID] = nil + casts_VisualizeCastSettings[castID] = nil + + casts_RayInfo[castID] = nil + casts_UserData[castID] = nil + + casts_CFrame[castID] = nil + casts_CastType[castID] = nil + casts_CastVariant[castID] = nil + + casts_MaxDistance[castID] = nil + casts_RayDisplacement[castID] = nil + + if casts_ActiveMotor6Ds[castID] then + if BaseCastRef + and BaseCastRef._ReturnMotor6D + then + BaseCastRef._ReturnMotor6D( + casts_ActiveMotor6Ds[castID] + ) + end + + casts_ActiveMotor6Ds[castID] = nil + end + + local index = casts_ID_Index[castID] + + if index then + local lastID = casts_ID[#casts_ID] + + casts_ID[index] = lastID + casts_ID_Index[lastID] = index + + table.remove(casts_ID, #casts_ID) + casts_ID_Index[castID] = nil + end +end + +-- ============================================================================ +-- Movement configuration +-- ============================================================================ + +local function SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" +) + local oldMode = CurrentMovementMode + + CurrentMovementMode = mode + MovementEnabled = enabled - self:SimulateCast(id, timeIncrement, self.Events.CanPierce) + if oldMode == "Motor6D" + and mode ~= "Motor6D" + then + for id, motor6d in casts_ActiveMotor6Ds do + if BaseCastRef + and BaseCastRef._ReturnMotor6D + then + BaseCastRef._ReturnMotor6D(motor6d) end - if cast_nil then - casts_IsActivelyResimulating[id] = false + casts_ActiveMotor6Ds[id] = nil + end + end + + if mode == "Motor6D" + and enabled + and oldMode ~= "Motor6D" + then + for _, id in casts_ID do + if casts_ActiveMotor6Ds[id] then continue end - -- Double check again - if self.ActivesRef[id] == nil then - casts_IsActivelyResimulating[id] = false - continue + local cosmetic = + casts_RayInfo[id].CosmeticBulletObject + + if cosmetic + and BaseCastRef + and BaseCastRef._GetMotor6D + then + casts_ActiveMotor6Ds[id] = + BaseCastRef._GetMotor6D(cosmetic) end - casts_IsActivelyResimulating[id] = false - else - self:SimulateCast(id, delta, self.Events.CanPierce) end end +end + +-- ============================================================================ +-- Main update +-- ============================================================================ + +local function UpdateCasts(delta: number) + for _, id in casts_ID do + local cast = ActivesRef[id] - -- Write-back SoA -> ActiveCast so ActivesRef[id] has current values - for _, id in self.casts_ID do - local cast = self.ActivesRef[id] + if cast and cast._Destroying then + continue + end + + if not casts_Trajectory[id] then + continue + end + + if casts_HighFidelitySegmentSize[id] <= 0 then + casts_HighFidelitySegmentSize[id] = 0.1 + end + + local behavior = casts_HighFidelityBehavior[id] + + if behavior == FastCastEnums.HighFidelityBehavior.Always then + SimulateAlwaysHighFidelity( + id, + delta + ) + else + SimulateCast( + id, + delta + ) + end + + -- Write-back SoA -> ActiveCast (merged to avoid second iteration) if cast then cast.StateInfo.TotalRuntime = casts_TotalRunTime[id] cast.StateInfo.DistanceCovered = casts_DistanceCovered[id] @@ -844,67 +1494,109 @@ function SerialSimulation:UpdateCasts(delta: number) end end - -- Motor6D, BulkMoveTo, FireQueuedVisualizes, FireQueuedEvents - -- NOTE: Do not change these if you don't knows how it works + -- ========================================================================= + -- Main-thread-only work. + -- ========================================================================= + + UpdateMotor6Ds() + BulkMoveTo() + + FireQueuedVisualizes() + FireQueuedEvents() +end + +-- ============================================================================ +-- Public API +-- ============================================================================ + +local SerialSimulation = {} + +SerialSimulation.Connection = nil :: RBXScriptConnection? + +function SerialSimulation.Init(baseCastRef: any, Actives: any, events: TypeDef.FastCastSignalEvents) + if ActivesRef ~= nil then + warn( + "[FastCast2] SerialSimulation already initialized. Skipping." + ) + + return + end - self:UpdateMotor6Ds() - self:BulkMoveTo() + BaseCastRef = baseCastRef + ActivesRef = Actives - local eventsToProcess = queuedEvents - queuedEvents = {} - local visualizesToProcess = queuedVisualizes - queuedVisualizes = {} + FastCastEvents = events +end - self:FireQueuedVisualizes(visualizesToProcess) - self:FireQueuedEvents(eventsToProcess) +function SerialSimulation.Register(cast: any) + SerialSimulation_Register(cast) end -function SerialSimulation.new() - local self = setmetatable({}, SerialSimulation) - self.Connection = nil - self.CurrentMovementMode = "BulkMoveTo" - self.MovementEnabled = true - self.Events = {} - self.BaseCastRef = nil - self.ActivesRef = nil - self.casts_ID = {} - self.casts_ID_Index = {} - return self +function SerialSimulation.Unregister(castID: number) + SerialSimulation_Unregister(castID) end -function SerialSimulation:Init(baseCastRef: any, events: TypeDef.FastCastEvents) - self.BaseCastRef = baseCastRef - self.ActivesRef = baseCastRef.Actives - self.Events = events +function SerialSimulation.SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" +) + SetMovementModeEnabled(enabled, mode) end -function SerialSimulation:Start() - if self.Connection then - warn("Already started") +--[[ + Please do not make it OOP, because I don't want this to be OOP for performance reasons + - Mawin_CK +]] + +function SerialSimulation.Start() + if SerialSimulation.Connection then + --warn("[FastCast2] SerialSimulation already started.") return end - if RS:IsClient() then - self.Connection = RS.PreSimulation:Connect(function(delta: number) - self:UpdateCasts(delta) - end) + if Config.SimulationMode == "Fixed" then + SerialSimulation.Connection = RS:BindToSimulation(function(dt) + UpdateCasts(dt) + end, Config.FixedSimulationFrequency) else - self.Connection = RS.Heartbeat:Connect(function(delta: number) - self:UpdateCasts(delta) - end) + if RS:IsClient() then + SerialSimulation.Connection = + RS.PreSimulation:Connect(UpdateCasts) + else + SerialSimulation.Connection = + RS.Heartbeat:Connect(UpdateCasts) + end end end -function SerialSimulation:Stop() - if self.Connection then - self.Connection:Disconnect() - self.Connection = nil +function SerialSimulation.Stop() + if SerialSimulation.Connection then + SerialSimulation.Connection:Disconnect() + SerialSimulation.Connection = nil end + ActivesRef = nil end --- Utils -function SerialSimulation:_UpdateEvents(eventName: string, newEventfn: (...any) -> ()) - self.Events[eventName] = newEventfn +function SerialSimulation.GetEvent(name: string): any + return FastCastEvents[name] +end + +function SerialSimulation.QueueEvent(castID: number, eventType: string, ...: any) + -- Public wrapper for external callers (e.g. init.luau TerminateCast) + -- Map string event type to numeric constant + local numericType: number + if eventType == "CastTerminating" then + numericType = EVT_CAST_TERMINATING + elseif eventType == "Hit" then + numericType = EVT_HIT + elseif eventType == "Pierced" then + numericType = EVT_PIERCED + elseif eventType == "LengthChanged" then + numericType = EVT_LENGTH_CHANGED + else + return + end + QueueEvent(castID, numericType, ...) end -return SerialSimulation \ No newline at end of file +return SerialSimulation diff --git a/src/Signal.luau b/src/Signal.luau new file mode 100644 index 00000000..116be180 --- /dev/null +++ b/src/Signal.luau @@ -0,0 +1,189 @@ +--[[ + Author: Darwin (darwin@shellworks.dev) + Date: 18/09/2026 + + Signal - a lightweight, synchronous event dispatcher. + + FastCast2 uses this instead of `BindableEvent` for Caster events. It has no + Instance overhead and, unlike a `BindableEvent`, supports any number of + independent listeners plus explicit `Disconnect`/`Reconnect`. + + This is a pure-Luau object and therefore cannot cross an Actor boundary; the + parallel worker VMs still use a `BindableEvent` as the transport and simply + `Fire` the Caster's signals on the main thread. +]] + +-- Types + +export type Connection = { + Connected: boolean, + + Disconnect: (self: Connection) -> (), + Reconnect: (self: Connection) -> (), +} + +export type Signal = { + Connect: (self: Signal, callback: (T...) -> ()) -> Connection, + Once: (self: Signal, callback: (T...) -> ()) -> Connection, + Wait: (self: Signal) -> T..., + Fire: (self: Signal, T...) -> (), + DisconnectAll: (self: Signal) -> (), + Destroy: (self: Signal) -> (), +} + +-- Connection + +local Connection = {} +Connection.__index = Connection +Connection.__type = "Connection" + +local function disconnect(self) + if not self.Connected then + return + end + + self.Connected = false + + local signal = self._signal + if signal == nil then + return + end + + local prev = self._prev + local next = self._next + + if prev then + prev._next = next + elseif signal._head == self then + signal._head = next + end + + if next then + next._prev = prev + end +end + +local function reconnect(self) + if self.Connected then + return + end + + local signal = self._signal + if signal == nil then + return + end + + self.Connected = true + + local head = signal._head + self._next = head + self._prev = nil + + if head then + head._prev = self + end + + signal._head = self +end + +Connection.Disconnect = disconnect +Connection.Reconnect = reconnect + +-- Signal + +local Signal = {} +Signal.__index = Signal +Signal.__type = "Signal" + +function Signal.new() + return setmetatable({ _head = nil, _destroyed = false }, Signal) +end + +function Signal.is(value: any): boolean + return type(value) == "table" and getmetatable(value) == Signal +end + +function Signal:Connect(callback) + assert(type(callback) == "function", "Signal:Connect expects a function") + + local connection = setmetatable({ + Connected = true, + _signal = self, + _fn = callback, + _prev = nil, + _next = self._head, + }, Connection) + + local head = self._head + if head then + head._prev = connection + end + self._head = connection + + return connection +end + +function Signal:Once(callback) + local connection + connection = self:Connect(function(...) + connection:Disconnect() + callback(...) + end) + return connection +end + +function Signal:Wait() + local thread = coroutine.running() + local connection + connection = self:Connect(function(...) + connection:Disconnect() + task.spawn(thread, ...) + end) + return coroutine.yield() +end + +function Signal:Fire(...) + if self._destroyed then + return + end + + local connection = self._head + while connection do + -- Capture the next link before firing so a listener that disconnects + -- itself (or another listener) cannot break traversal. + local nextConnection = connection._next + if connection.Connected then + connection._fn(...) + end + connection = nextConnection + end +end + +function Signal:DisconnectAll() + local connection = self._head + while connection do + local nextConnection = connection._next + connection:Disconnect() + connection = nextConnection + end + self._head = nil +end + +function Signal:Destroy() + self._destroyed = true + self:DisconnectAll() +end + +--[[ + Wrap an `RBXScriptSignal` so it can be used like a Signal. Useful when + building APIs that mix Roblox signals with FastCast2 signals. +]] +function Signal.wrap(rbxSignal) + local signal = Signal.new() + signal._rbxConnection = rbxSignal:Connect(function(...) + signal:Fire(...) + end) + return signal +end + +return Signal diff --git a/src/TypeDefinitions.luau b/src/TypeDefinitions.luau index 2be2b9ca..679f53ae 100644 --- a/src/TypeDefinitions.luau +++ b/src/TypeDefinitions.luau @@ -14,6 +14,7 @@ ]=] local Dispatcher = require(script.Parent:WaitForChild("FastCastVMs")) +local Signal = require(script.Parent:WaitForChild("Signal")) --[=[ @type vaildcast ActiveCastData | ActiveBlockcastData | ActiveSpherecastData @@ -47,6 +48,22 @@ export type FastCastEvents = { CastFire: OnCastFireFunction } +--[=[ + @type FastCastSignalEvents { CanPierce: CanPierceFunction, Hit: Signal.Signal, Pierced: Signal.Signal, LengthChanged: Signal.Signal, CastTerminating: Signal.Signal, CastFire: Signal.Signal } + @within TypeDefinitions + + The Signal-based event table used internally by SerialSimulation. Each event + is a Signal so any number of listeners can connect to it. +]=] +export type FastCastSignalEvents = { + CanPierce: CanPierceFunction, + Hit: Signal.Signal, + Pierced: Signal.Signal, + LengthChanged: Signal.Signal, + CastTerminating: Signal.Signal, + CastFire: Signal.Signal +} + --[=[ @type CanPierceFunction (cast: vaildcast, result: RaycastResult, segmentVelocity: Vector3, cosmeticBulletObject: Instance?) -> boolean @within TypeDefinitions @@ -157,11 +174,11 @@ export type VisualizeCastSettings = { ]=] export type CasterParallel = { WorldRoot: WorldRoot, - LengthChanged: OnLengthChangedFunction, - Hit: OnHitFunction, - Pierced: OnPiercedFunction, - CastTerminating: OnCastTerminatingFunction, - CastFire: OnCastFireFunction, + LengthChanged: Signal.Signal | OnLengthChangedFunction, + Hit: Signal.Signal | OnHitFunction, + Pierced: Signal.Signal | OnPiercedFunction, + CastTerminating: Signal.Signal | OnCastTerminatingFunction, + CastFire: Signal.Signal | OnCastFireFunction, Dispatcher: Dispatcher.Dispatcher, AlreadyInit: boolean, @@ -172,11 +189,6 @@ export type CasterParallel = { Init: ( self: CasterParallel, numWorkers: number, - newParent: Folder, - newName: string, - ContainerParent: Folder, - VMContainerName: string, - VMname: string, MovementMode: "BulkMoveTo" | "Motor6D", FastCastEventsModule: ModuleScript, useObjectCache: boolean, @@ -211,16 +223,9 @@ export type CasterParallel = { ) -> (), SetMovementModeEnabled: ( - enabled: boolean, - mode: "BulkMoveTo" | "Motor6D" - ) -> (), - - SetObjectCacheEnabled: ( self: CasterParallel, enabled: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance + mode: "BulkMoveTo" | "Motor6D" ) -> (), SetFastCastEventsModule: (self: CasterParallel, moduleScript: ModuleScript) -> (), @@ -229,7 +234,7 @@ export type CasterParallel = { SetVelocityCast: (CasterParallel, cast: vaildcast, velocity: Vector3) -> (), GetVelocityCast: (CasterParallel, cast: vaildcast) -> Vector3, - AddAccelerationCast: (CasterParallel, cast: vaildcast, acceleration: Vector3) -> Vector3, + AddAccelerationCast: (CasterParallel, cast: vaildcast, acceleration: Vector3) -> (), SetAccelerationCast: (CasterParallel, cast: vaildcast, acceleration: Vector3) -> (), GetAccelerationCast: (CasterParallel, cast: vaildcast) -> Vector3, @@ -252,12 +257,12 @@ export type CasterParallel = { ]=] export type CasterSerial = { WorldRoot: WorldRoot, - LengthChanged: OnLengthChangedFunction, - Hit: OnHitFunction, + LengthChanged: Signal.Signal | OnLengthChangedFunction, + Hit: Signal.Signal | OnHitFunction, CanPierce: CanPierceFunction, - Pierced: OnPiercedFunction, - CastTerminating: OnCastTerminatingFunction, - CastFire: OnCastFireFunction, + Pierced: Signal.Signal | OnPiercedFunction, + CastTerminating: Signal.Signal | OnCastTerminatingFunction, + CastFire: Signal.Signal | OnCastFireFunction, Dispatcher: Dispatcher.Dispatcher, AlreadyInit: boolean, @@ -300,23 +305,16 @@ export type CasterSerial = { ) -> (), SetMovementModeEnabled: ( - enabled: boolean, - mode: "BulkMoveTo" | "Motor6D" - ) -> (), - - SetObjectCacheEnabled: ( self: CasterSerial, enabled: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance + mode: "BulkMoveTo" | "Motor6D" ) -> (), AddVelocityCast: (CasterSerial, cast: vaildcast, velocity: Vector3) -> (), SetVelocityCast: (CasterSerial, cast: vaildcast, velocity: Vector3) -> (), GetVelocityCast: (CasterSerial, cast: vaildcast) -> Vector3, - AddAccelerationCast: (CasterSerial, cast: vaildcast, acceleration: Vector3) -> Vector3, + AddAccelerationCast: (CasterSerial, cast: vaildcast, acceleration: Vector3) -> (), SetAccelerationCast: (CasterSerial, cast: vaildcast, acceleration: Vector3) -> (), GetAccelerationCast: (CasterSerial, cast: vaildcast) -> Vector3, @@ -359,7 +357,7 @@ export type FastCastEventsConfig = { } --[=[ - @type FastCastBehavior { RaycastParams: RaycastParams?, MaxDistance: number, Acceleration: Vector3, HighFidelityBehavior: number, HighFidelitySegmentSize: number, CosmeticBulletTemplate: Instance?, CosmeticBulletContainer: Instance?, AutoIgnoreContainer: boolean, MovementMethod: "BulkMoveTo" | "Transform", FastCastEventsModuleConfig: FastCastEventsModuleConfig, VisualizeCasts: boolean, VisualizeCastSettings: VisualizeCastSettings, FastCastEventsConfig: FastCastEventsConfig, UserData: any } + @type FastCastBehavior { RaycastParams: RaycastParams?, MaxDistance: number, Acceleration: Vector3, HighFidelityBehavior: number, HighFidelitySegmentSize: number, CosmeticBulletTemplate: Instance?, CosmeticBulletContainer: Instance?, AutoIgnoreContainer: boolean, MovementMethod: "BulkMoveTo" | "Transform", FastCastEventsModuleConfig: FastCastEventsModuleConfig, VisualizeCasts: boolean, VisualizeCastSettings: VisualizeCastSettings, CloneTemplate: boolean, FastCastEventsConfig: FastCastEventsConfig, UserData: any } @within TypeDefinitions Represents a FastCastBehavior configuration. @@ -378,6 +376,8 @@ export type FastCastBehavior = { VisualizeCasts: boolean, VisualizeCastSettings: VisualizeCastSettings, + CloneTemplate: boolean, + FastCastEventsConfig: FastCastEventsConfig, UserData: any } @@ -464,14 +464,14 @@ export type SphereCastRayInfo = { } --[=[ - @type BaseCastData { Output: BindableEvent, ActiveCastCleaner: BindableEvent, ObjectCache: BindableFunction?, CacheHolder: any?, SyncChange : BindableEvent } + @type BaseCastData { Output: BindableEvent, Actor: Actor, ObjectCache: BindableFunction?, CacheHolder: any?, SyncChange : BindableEvent } @within TypeDefinitions Data stored on the caster that ActiveCasts reference. ]=] export type BaseCastData = { Output: BindableEvent, - ActiveCastCleaner: BindableEvent, + Actor: Actor, CacheHolder: any?, SyncChange : BindableEvent } @@ -491,7 +491,8 @@ export type ActiveCastData = { _Destroying: boolean, Type : "Raycast", CFrame: CFrame, - ID: number | string + ID: number, + Mode: "Serial" | "Parallel", } --[=[ @@ -509,7 +510,8 @@ export type ActiveBlockcastData = { _Destroying: boolean, Type : "Blockcast", CFrame: CFrame, - ID: number | string + ID: number, + Mode: "Serial" | "Parallel", } --[=[ @@ -527,7 +529,8 @@ export type ActiveSpherecastData = { _Destroying: boolean, Type : "Spherecast", CFrame: CFrame, - ID: number | string + ID: number, + Mode: "Serial" | "Parallel", } return {} diff --git a/src/init.luau b/src/init.luau index 22f3f8ed..81b58565 100644 --- a/src/init.luau +++ b/src/init.luau @@ -13,7 +13,7 @@ YOU SHOULD ONLY CREATE ONE CASTER PER GUN. YOU SHOULD >>>NEVER<<< CREATE A NEW CASTER EVERY TIME THE GUN NEEDS TO BE FIRED. - A caster (created with FastCast.new() or FastCastParallel.new()) represents a "gun". + A caster (created with FastCast.new()) represents a "gun". When you consider a gun, you think of stats like accuracy, bullet speed, etc. This is the info a caster stores. -- @@ -31,7 +31,7 @@ Unfortunately, while reliable in terms of saying if something got hit or not, this method alone cannot be used if you wish to implement bullet travel time into a weapon. As a result of that, I made this library - an excellent remedy to this dilemma. - FastCastParallel is intended to be require()'d once in a script, as you can create as many casters as you need with FastCastParallel.new() + FastCast is intended to be require()'d once in a script, as you can create as many casters as you need with FastCast.new() This is generally handy since you can store settings and information in these casters, and even send them out to other scripts via events for use. @@ -39,11 +39,18 @@ Make the caster once, then use the caster to fire your bullets. Do not make a caster for each bullet. --]] --- Mozilla Public License 2.0 (files originally from FastCastParallel) +-- Mozilla Public License 2.0 (files originally from FastCast) --[[ - Modified by: Mawin CK - Date : 2025 + + NOTE: + For anyone who's reading this is FastCast2, An improved version of FastCast with Parallel scripting, more extensions, and statically typed. +A powerful modern projectile library + + Because FastCast is no longer actively maintained by EtiTheSpirit, this repository continues the project with updates and adaptations. + - Devforum: https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed-a-powerful-modern-projectile-library/4093890 ]] @@ -56,16 +63,23 @@ -- Modules --local BaseCast = script:WaitForChild("BaseCast") +-- Services + +local RunService = game:GetService("RunService") + -- Requires +local Signal = require(script:WaitForChild("Signal")) local TypeDef = require(script:WaitForChild("TypeDefinitions")) local DefaultConfigs = require(script:WaitForChild("DefaultConfigs")) local BaseCastSerial = require(script:WaitForChild("BaseCastSerial")) +local SerialSimulation = require(script:WaitForChild("SerialSimulation")) local DispatcherModule = script:WaitForChild("FastCastVMs") local Dispatcher = require(DispatcherModule) -- Type Exports export type FastCastEventsModule = TypeDef.FastCastEventsModule export type FastCastEvents = TypeDef.FastCastEvents +export type FastCastSignalEvents = TypeDef.FastCastSignalEvents export type CanPierceFunction = TypeDef.CanPierceFunction export type OnHitFunction = TypeDef.OnHitFunction @@ -100,15 +114,170 @@ type vaildcast = TypeDef.ActiveCastData | TypeDef.ActiveBlockcastData | TypeDef. -- CONSTANTS local DEFAULT_CACHE_SIZE = 500 local DEFAULT_CACHE_HOLDER = workspace -local VALID_EVENTS = { + +-- Events exposed on a Caster as a Signal. Assigning a function to one of these +-- names is equivalent to connecting that function to the underlying Signal. +local SIGNAL_EVENTS = { ["CastFire"] = true, ["CastTerminating"] = true, ["Hit"] = true, ["Pierced"] = true, - ["LengthChanged"] = true, + ["LengthChanged"] = true +} + +-- CanPierce is a query (it has to return a boolean), so it stays a plain function. +local QUERY_EVENTS = { ["CanPierce"] = true } +-- Mirrors the EVT_* numeric constants in ParallelSimulation. Parallel workers +-- send one "Batch" message per frame containing records tagged with these IDs; +-- the main thread demuxes them back into the Caster's signals. +local OUTPUT_EVENT_NAMES = { + [1] = "LengthChanged", + [2] = "Hit", + [3] = "Pierced", + [4] = "CastTerminating" +} + +--[[ + Note: On the client, `PARALLEL_PARENT` must be a script-enabled container + (for example `ReplicatedFirst`). Worker controllers placed in a container + that does not run scripts never initialize, and no casts are simulated. +]] +-- Parallel luau related +local PARALLEL_PARENT +if RunService:IsServer() then + PARALLEL_PARENT = game:GetService("ServerScriptService") +else + PARALLEL_PARENT = game:GetService("ReplicatedFirst") +end +local VM_NAME = "FastCastVM" +local VM_CONTAINER_NAME = "FastCastVMsContainer" +local DISPATCHER_NAME = "FastCastVMs" + +local function NewCasterSignals() + return { + CastFire = Signal.new(), + CastTerminating = Signal.new(), + Hit = Signal.new(), + Pierced = Signal.new(), + LengthChanged = Signal.new() + } +end + +local function DestroyCasterSignals(self) + local legacy = rawget(self, "_legacy") + if legacy then + for _, connection in legacy do + connection:Disconnect() + end + rawset(self, "_legacy", nil) + end + + local events = rawget(self, "_events") + if events then + for _, signal in events do + signal:Destroy() + end + rawset(self, "_events", nil) + end + + rawset(self, "CanPierce", nil) +end + +local function IndexCaster(self, key) + local events = rawget(self, "_events") + if events then + local signal = events[key] + if signal ~= nil then + return signal + end + end + + local mt = getmetatable(self) + if mt then + return mt[key] + end + return nil +end + +local function NewIndexCaster(self, key, value) + if SIGNAL_EVENTS[key] then + local events = rawget(self, "_events") + if not events then + events = NewCasterSignals() + rawset(self, "_events", events) + end + + if type(value) == "function" then + -- Backwards compatible assignment: connect the function, replacing + -- any previous function assigned this way. + local legacy = rawget(self, "_legacy") + if not legacy then + legacy = {} + rawset(self, "_legacy", legacy) + end + + local previous = legacy[key] + if previous then + previous:Disconnect() + end + + legacy[key] = events[key]:Connect(value) + elseif Signal.is(value) then + local legacy = rawget(self, "_legacy") + if legacy and legacy[key] then + legacy[key]:Disconnect() + legacy[key] = nil + end + + events[key] = value + elseif value == nil then + local legacy = rawget(self, "_legacy") + if legacy and legacy[key] then + legacy[key]:Disconnect() + legacy[key] = nil + end + else + warn("[FastCast2] Cannot set event '" .. tostring(key) .. "', expected a function or Signal") + end + return + end + + if QUERY_EVENTS[key] then + if value == nil or type(value) == "function" then + rawset(self, key, value) + else + warn("[FastCast2] Cannot set CanPierce, expected a function") + end + return + end + + rawset(self, key, value) +end + +-- Demux a batch of worker events (see ParallelSimulation.FireQueuedEvents). +-- Record layout: { eventType, cast, args, argCount } +local function DispatchOutputBatch(self, batch) + for i = 1, #batch do + local record = batch[i] + local signalName = OUTPUT_EVENT_NAMES[record[1]] + + if signalName then + local signal = self[signalName] + if Signal.is(signal) then + local argCount = record[4] + if argCount > 0 then + signal:Fire(record[2], table.unpack(record[3], 1, argCount)) + else + signal:Fire(record[2]) + end + end + end + end +end + --[=[ @class FastCast @@ -145,25 +314,12 @@ If true, verbose debug logging will be used, printing detailed information about what's going on during processing to the output. ]] -FastCastSerial.__index = FastCastSerial -FastCastSerial.__newindex = function(self, key, value) - if VALID_EVENTS[key] then - if type(value) == "function" then - if self.BaseCast then - self.BaseCast:_UpdateEvents(key, value) - else - rawset(self, key, value) - end - else - warn("Cannot set event, not a function") - end - else - rawset(self, key, value) - end -end +FastCastSerial.__index = IndexCaster +FastCastSerial.__newindex = NewIndexCaster FastCastSerial.__type = "FastCastSerial" -FastCastParallel.__index = FastCastParallel +FastCastParallel.__index = IndexCaster +FastCastParallel.__newindex = NewIndexCaster FastCastParallel.__type = "FastCastParallel" -- Local functions @@ -247,78 +403,129 @@ function FastCast.newBehavior(): TypeDef.FastCastBehavior return deepCopyTable(DefaultConfigs.FastCastBehavior) :: TypeDef.FastCastBehavior end +-- Batched parallel dispatch. Shots are queued and sent once per frame per +-- worker instead of one message per shot, removing the per-shot cost of +-- crossing the Actor boundary. The behavior is snapshotted when queued so +-- per-shot mutations (such as the raycast filter) are preserved. + +local function CloneCastParams(params: RaycastParams): RaycastParams + local clone = RaycastParams.new() + clone.CollisionGroup = params.CollisionGroup + clone.FilterType = params.FilterType + clone.FilterDescendantsInstances = table.clone(params.FilterDescendantsInstances) + clone.IgnoreWater = params.IgnoreWater + clone.RespectCanCollide = params.RespectCanCollide + return clone +end + +local function SnapshotBehavior(behavior: any): any + local copy = deepCopyTable(behavior) + if behavior.RaycastParams then + copy.RaycastParams = CloneCastParams(behavior.RaycastParams) + end + return copy +end + +local function QueueFire(self: any, request: { any }) + local queue = self._fireQueue + queue[#queue + 1] = request +end + +local function FlushFires(self: any) + local queue = self._fireQueue + local count = #queue + if count == 0 then + return + end + + local dispatcher = self.Dispatcher + local threads = dispatcher and dispatcher.Threads + local workerCount = if threads then #threads else 0 + if workerCount == 0 then + table.clear(queue) + return + end + + local batches = self._fireBatches + local used = self._fireUsed + local usedCount = 0 + + for i = 1, count do + local worker = (i - 1) % workerCount + 1 + local batch = batches[worker] + if not batch then + batch = {} + batches[worker] = batch + usedCount += 1 + used[usedCount] = worker + end + batch[#batch + 1] = queue[i] + end + table.clear(queue) + + for i = 1, usedCount do + local worker = used[i] + local batch = batches[worker] + threads[worker]:SendMessage("FireBatch", batch) + table.clear(batch) + used[i] = nil + end +end + --[=[ Initializes the Caster with the given parameters. This is required before firing using Raycasts in the Caster or nothing will happen! @method Init @within FastCastParallel - - @param numWorkers number -- The number of worker VMs to create for this Caster. Must be greater than 1. - @param newParent Folder -- The Folder in which to place the FastCastVMs Folder - @param newName string -- The name to give the FastCastVMs Folder containing worker scripts. - @param ContainerParent Folder -- The parent Folder in which to place the worker VM Containers. - @param VMContainerName Folder -- The name to give to the Containers housing each worker VM. - @param VMname string -- The name to give each worker VM. - @param useBulkMoveTo boolean -- Whether to enable BulkMoveTo for the [CosmeticBulletObjects](TypeDefinitions#CastRayInfo) + + @param numWorkers number -- Number of workers for parallel luau + @param movementMode "BulkMoveTo" | "Motor6D" -- MovementMode for projectiles @param FastCastEventsModule ModuleScript -- The ModuleScript containing the FastCastEvents, A table of callback functions (events/hooks) used by ActiveCast.. - @param useObjectCache boolean -- Whether to use ObjectCache for the [Caster](TypeDefinitions#Caster) - @param Template BasePart | Model -- The template object to use for the ObjectCache (if enabled) - @param CacheSize number -- The size of the ObjectCache (if enabled) - @param CacheHolder Instance -- The Instance in which to place cached objects (if enabled) ]=] function FastCastParallel:Init( numWorkers: number, - newParent: Folder, - newName: string, - ContainerParent: Folder, - VMContainerName: string, - VMname: string, movementMode: "BulkMoveTo" | "Motor6D", - FastCastEventsModule: ModuleScript, - - useObjectCache: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance + FastCastEventsModule: ModuleScript ) if self.AlreadyInit then warn("Cannot Init more than 1") return end - assert(numWorkers > 1, "numWorker must be more than 1") + assert(numWorkers >= 1, "numWorker must be more than 1") local DispatcherClone = DispatcherModule:Clone() - DispatcherClone.Parent = newParent - DispatcherClone.Name = newName or "FastCastVMs" + DispatcherClone.Parent = PARALLEL_PARENT + DispatcherClone.Name = DISPATCHER_NAME local newDispatcher: Dispatcher.Dispatcher = require(DispatcherClone) :: Dispatcher.Dispatcher - newDispatcher.Init(ContainerParent, VMContainerName, VMname) + newDispatcher.Init(PARALLEL_PARENT, VM_CONTAINER_NAME, VM_NAME) local data = { movementMode = movementMode, - useObjectCache = useObjectCache, - objectCacheArgs = { - Template = Template, - CacheSize = CacheSize, - CacheHolder = CacheHolder - }, FastCastModule = script } - self.Dispatcher = newDispatcher.new(numWorkers, data, function(signalName: string, ...) - local f = self[signalName] - if not f then + self.Dispatcher = newDispatcher.new(numWorkers, data, function(eventName: string, ...) + if eventName == "Batch" then + DispatchOutputBatch(self, ...) return end - if type(f) == "function" then - f(...) + local signal = self[eventName] + if Signal.is(signal) then + signal:Fire(...) end end) + -- Queue shots and flush them once per frame (see FlushFires). + self._fireQueue = {} + self._fireBatches = {} + self._fireUsed = {} + self._flushConnection = RunService.PreSimulation:Connect(function() + FlushFires(self) + end) self.AlreadyInit = true - self.ObjectCacheEnabled = useObjectCache self.MovementMode = movementMode if FastCastEventsModule then @@ -366,7 +573,13 @@ function FastCastParallel:RaycastFire( BehaviorData = FastCast.newBehavior() end - self.Dispatcher:Dispatch("Raycast", origin, direction, velocity, BehaviorData) + QueueFire(self, { + 1, + origin, + direction, + velocity, + SnapshotBehavior(BehaviorData) + }) end --[=[ @@ -394,7 +607,14 @@ function FastCastParallel:BlockcastFire( BehaviorData = FastCast.newBehavior() end - self.Dispatcher:Dispatch("Blockcast", origin, Size, direction, velocity, BehaviorData) + QueueFire(self, { + 2, + origin, + Size, + direction, + velocity, + SnapshotBehavior(BehaviorData) + }) end --[=[ @@ -422,7 +642,14 @@ function FastCastParallel:SpherecastFire( BehaviorData = FastCast.newBehavior() end - self.Dispatcher:Dispatch("Spherecast", origin, Radius, direction, velocity, BehaviorData) + QueueFire(self, { + 3, + origin, + Radius, + direction, + velocity, + SnapshotBehavior(BehaviorData) + }) end --[=[ @@ -443,34 +670,6 @@ function FastCastParallel:SetMovementModeEnabled(enabled: boolean, mode: "BulkMo self.MovementMode = mode end ---[=[ - Sets whether ObjectCache is enabled for this Caster. - It is recommended to interface with this via [`FastCastParallel:Init()`](FastCastParallel#Init) instead. - @method SetObjectCacheEnabled - @within FastCastParallel - - @param enabled boolean -]=] -function FastCastParallel:SetObjectCacheEnabled( - enabled: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance -) - if not self.AlreadyInit then - error("Please Init caster") - end - local vmDispatcher = self.Dispatcher - - if enabled then - vmDispatcher:DispatchAll("BindObjectCache", enabled, Template, CacheSize, CacheHolder) - else - vmDispatcher:DispatchAll("BindObjectCache", enabled) - end - - self.ObjectCacheEnabled = enabled -end - -- Serial Caster Methods --[=[ @@ -478,18 +677,10 @@ end @method Init @within FastCastSerial - @param useBulkMoveTo boolean -- Whether to use BulkMoveTo for projectile movement. - @param useObjectCache boolean -- Whether to use ObjectCache. - @param Template BasePart | Model? -- Template for ObjectCache. - @param CacheSize number? -- Size of ObjectCache. - @param CacheHolder Instance? -- Parent for cached objects. + @param movementMode: "BulkMoveTo" | "Motor6D" -- Whether to use BulkMoveTo or Motor6D Transform for projectile movement. ]=] function FastCastSerial:Init( - movementMode: "BulkMoveTo" | "Motor6D", - useObjectCache: boolean, - Template: BasePart | Model?, - CacheSize: number?, - CacheHolder: Instance? + movementMode: "BulkMoveTo" | "Motor6D" ) if self.BaseCast then warn("Serial Caster already initialized") @@ -497,16 +688,10 @@ function FastCastSerial:Init( end local data = { - movementMode = movementMode or "BulkMoveTo", - useObjectCache = useObjectCache, - objectCacheArgs = { - Template = Template, - CacheSize = CacheSize or DEFAULT_CACHE_SIZE, - CacheHolder = CacheHolder or DEFAULT_CACHE_HOLDER - } + movementMode = movementMode or "BulkMoveTo" } - local events: TypeDef.FastCastEvents = { + local events: TypeDef.FastCastSignalEvents = { CastFire = self.CastFire, Pierced = self.Pierced, Hit = self.Hit, @@ -515,7 +700,8 @@ function FastCastSerial:Init( CastTerminating = self.CastTerminating } - self.BaseCast = BaseCastSerial.Init(events, data) + BaseCastSerial.Init(events, data) + self.BaseCast = BaseCastSerial self.MovementMode = movementMode or "BulkMoveTo" self.AlreadyInit = true @@ -529,7 +715,7 @@ function FastCastSerial:RaycastFire( origin: Vector3, direction: Vector3, velocity: Vector3 | number, - BehaviorData: TypeDef.FastCastBehavior? + BehaviorData: TypeDef.FastCastBehavior ) if not self.AlreadyInit then error("Please Init caster first") @@ -538,7 +724,7 @@ function FastCastSerial:RaycastFire( BehaviorData = FastCast.newBehavior() end - self.BaseCast:Raycast(origin, direction, velocity, BehaviorData) + BaseCastSerial.Raycast(origin, direction, velocity, BehaviorData) end --[=[ @@ -550,7 +736,7 @@ function FastCastSerial:BlockcastFire( Size: Vector3, direction: Vector3, velocity: Vector3 | number, - BehaviorData: TypeDef.FastCastBehavior? + BehaviorData: TypeDef.FastCastBehavior ) if not self.AlreadyInit then error("Please Init caster first") @@ -559,7 +745,7 @@ function FastCastSerial:BlockcastFire( BehaviorData = FastCast.newBehavior() end - self.BaseCast:Blockcast(origin, Size, direction, velocity, BehaviorData) + BaseCastSerial.Blockcast(origin, Size, direction, velocity, BehaviorData) end --[=[ @@ -571,7 +757,7 @@ function FastCastSerial:SpherecastFire( Radius: number, direction: Vector3, velocity: Vector3 | number, - BehaviorData: TypeDef.FastCastBehavior? + BehaviorData: TypeDef.FastCastBehavior ) if not self.AlreadyInit then error("Please Init caster first") @@ -580,7 +766,7 @@ function FastCastSerial:SpherecastFire( BehaviorData = FastCast.newBehavior() end - self.BaseCast:Spherecast(origin, Radius, direction, velocity, BehaviorData) + BaseCastSerial.Spherecast(origin, Radius, direction, velocity, BehaviorData) end --[[ @@ -594,31 +780,10 @@ end function FastCastSerial:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") if not self.BaseCast then return end - self.BaseCast:SetMovementModeEnabled(enabled, mode) + BaseCastSerial.SetMovementModeEnabled(enabled, mode) self.MovementMode = mode end ---[=[ - @method SetObjectCacheEnabled - @within FastCastSerial -]=] -function FastCastSerial:SetObjectCacheEnabled( - enabled: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance -) - if not self.BaseCast then return end - - self.BaseCast:BindObjectCache( - enabled, - Template, - CacheSize, - CacheHolder - ) - self.ObjectCacheEnabled = enabled -end - --[=[ @method Destroy @within FastCastSerial @@ -627,18 +792,10 @@ function FastCastSerial:Destroy() if getmetatable(self) ~= FastCastSerial then return end - setmetatable(self, nil) - - if self.BaseCast then - self.BaseCast:Destroy() - end - self.LengthChanged = nil - self.Hit = nil - self.Pierced = nil - self.CanPierce = nil - self.CastTerminating = nil - self.CastFire = nil + BaseCastSerial.Destroy() + DestroyCasterSignals(self) + setmetatable(self, nil) self.Destroy = function() end end @@ -653,15 +810,16 @@ function FastCastParallel:Destroy() return end - self.LengthChanged = nil - self.Hit = nil - self.Pierced = nil - self.CastTerminating = nil - self.CastFire = nil + if self._flushConnection then + self._flushConnection:Disconnect() + self._flushConnection = nil + end if self.Dispatcher then self.Dispatcher:Destroy() end + + DestroyCasterSignals(self) self.Destroy = function() end setmetatable(self, nil) end @@ -826,36 +984,33 @@ end Note: If EndTime is already set, the cast is already terminated and this function returns early. ]=] function FastCast:TerminateCast(cast: vaildcast) - if cast._Destroying then return end - (cast :: any)._Destroying = true + if cast._Destroying or cast._TerminationQueued then + return + end - local caster = cast.Caster - if caster == nil then return end + if cast.Mode == "Serial" then + cast._Destroying = true + cast._TerminationQueued = true - local eventsCfg = cast.StateInfo and cast.StateInfo.FastCastEventsConfig + SerialSimulation.QueueEvent( + cast.ID, + "CastTerminating" + ) - if caster.Output then - -- Parallel mode: fire event + cleaner, but don't nil cast keys - if eventsCfg and eventsCfg.UseCastTerminating then - caster.Output:Fire("CastTerminating", cast) - end - caster.ActiveCastCleaner:Fire(cast.ID) - elseif caster.SerialSimulation then - -- Serial mode: fire callback sync, destroy cosmetic immediately, queue cleanup - if eventsCfg and eventsCfg.UseCastTerminating and caster.SerialSimulation.Events.CastTerminating then - caster.SerialSimulation.Events.CastTerminating(cast) + elseif cast.Mode == "Parallel" then + local caster = cast.Caster + if caster == nil then + return end - if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if caster.ObjectCacheInstance then - caster.ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) - else - cast.RayInfo.CosmeticBulletObject:Destroy() - end - (cast :: any).RayInfo.CosmeticBulletObject = nil + cast._Destroying = true + + local eventsCfg = cast.StateInfo and cast.StateInfo.FastCastEventsConfig + if eventsCfg and eventsCfg.UseCastTerminating then + caster.Output:Fire("CastTerminating", cast) end - caster.SerialSimulation:QueueEvent(cast.ID, "CastTerminating") + caster.Actor:SendMessage("Cleanup", cast.ID) end end @@ -872,12 +1027,7 @@ end ]=] function FastCast.new() local fs = { - LengthChanged = nil, - Hit = nil, - Pierced = nil, - CanPierce = nil, - CastTerminating = nil, - CastFire = nil, + _events = NewCasterSignals(), WorldRoot = workspace, } setmetatable(fs, FastCastSerial) @@ -899,11 +1049,7 @@ end ]=] function FastCast.newParallel() local fp = { - LengthChanged = nil, - Hit = nil, - Pierced = nil, - CastTerminating = nil, - CastFire = nil, + _events = NewCasterSignals(), WorldRoot = workspace, Dispatcher = nil, AlreadyInit = false diff --git a/tests.project.json b/tests.project.json new file mode 100644 index 00000000..6d931801 --- /dev/null +++ b/tests.project.json @@ -0,0 +1,16 @@ +{ + "name": "FastCast2Tests", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "FastCast2": { + "$path": "src" + } + }, + "ServerScriptService": { + "FastCast2Tests": { + "$path": "tests/src" + } + } + } +} diff --git a/tests/Entry.luau b/tests/Entry.luau new file mode 100644 index 00000000..d593c2aa --- /dev/null +++ b/tests/Entry.luau @@ -0,0 +1,67 @@ +--[[ + run-in-roblox entry script. + + Starts the simulation (which boots the server harness in + ServerScriptService), waits for its JSON report, and re-emits it with a + stable sentinel so the Lune orchestrator can parse it from stdout. + + The report is picked up either from the ReplicatedStorage attribute the + harness sets or, as a fallback, from the sentinel the harness prints. +]] + +local HttpService = game:GetService("HttpService") +local LogService = game:GetService("LogService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local RESULT_ATTRIBUTE = "FastCast2TestResults" +local SENTINEL = "FASTCAST_SUITE_RESULTS" +local TIMEOUT_SECONDS = 600 + +local function extract(message: string): string? + local start = string.find(message, SENTINEL, 1, true) + if not start then + return nil + end + + local candidate = string.gsub(string.sub(message, start + #SENTINEL), "^%s+", "") + + -- Ignore log-relayed copies that were truncated in transit. + local ok = pcall(HttpService.JSONDecode, HttpService, candidate) + if not ok then + return nil + end + + return candidate +end + +if not RunService:IsRunning() then + RunService:Run() +end + +local encoded = ReplicatedStorage:GetAttribute(RESULT_ATTRIBUTE) + +local attributeConnection = ReplicatedStorage:GetAttributeChangedSignal(RESULT_ATTRIBUTE):Connect(function() + if encoded == nil then + encoded = ReplicatedStorage:GetAttribute(RESULT_ATTRIBUTE) + end +end) + +local logConnection = LogService.MessageOut:Connect(function(message) + if encoded == nil then + encoded = extract(message) + end +end) + +local deadline = os.clock() + TIMEOUT_SECONDS +while encoded == nil and os.clock() < deadline do + task.wait(0.25) + encoded = encoded or ReplicatedStorage:GetAttribute(RESULT_ATTRIBUTE) +end + +attributeConnection:Disconnect() +logConnection:Disconnect() + +assert(encoded ~= nil, "Timed out waiting for the FastCast2 test harness") + +print(SENTINEL .. " " .. encoded) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..8220391f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,78 @@ +# Tests + +Automated FastCast2 test + benchmark suite. One command builds a Rojo place, +runs it inside Roblox Studio with `run-in-roblox`, renders the results as a +Markdown table, and uploads the report to +[paste.shellworks.dev](https://paste.shellworks.dev). + +## Prerequisites + +FastCast2 exercises real Roblox APIs (raycasts, `BulkMoveTo`, Actor-based +parallel VMs), so the suite runs inside Studio rather than a standalone Luau +runtime. + +1. Install the toolchain (Rojo, Lune, `run-in-roblox`, Selene): + ```sh + rokit install + ``` + `run-in-roblox` can also be installed with `cargo install run-in-roblox`. +2. Make sure Roblox Studio is installed and signed in. The suite opens Studio + automatically and closes it when finished. + +## Run it + +```sh +npm test +# or +lune run tests/run.luau +``` + +Flags: + +| Flag | Description | +|------|-------------| +| `--no-upload` | Skip the `paste.shellworks.dev` upload and just print the report | +| `--keep` | Keep the built place in `./build` for manual inspection | + +Example: `npm test -- --no-upload` + +## What runs + +For focused buffer/math regressions and optional direct simulation A/B timing, +see [the hot-path runner](hotpaths/README.md) (`npm run test:hotpaths`). + +- **Functional tests** (`tests/src/FunctionalTests.luau`) follow the checklist + in [`TESTS.md`](../TESTS.md): initialization, raycast/blockcast/spherecast + firing, every public event, movement modes, cast manipulation, high-fidelity behavior, + and the parallel-only extensions + (`SetFastCastEventsModule`, `SyncChangesToCast`). +- **Benchmarks** (`tests/src/Benchmarks.luau`) run a fixed six-case matrix of + Serial/Parallel x Raycast/Blockcast/Spherecast, reporting creation and + cleanup cost plus simulation frame-time percentiles. Adjust projectile count, + sample duration, and worker count in `Benchmarks.DefaultConfig`. + +> **Note:** "Avg frame"/"P95"/"P99" are Heartbeat intervals, so they are +> bounded by Studio's frame rate (about `16.7 ms` at the default 60 FPS cap). +> Use them to compare cases within a single run, not across machines. + +The console and the uploaded report both include this table: + +``` +| Mode | Cast | Create | Avg frame | P95 | P99 | Cleanup | +``` + +## How it works + +``` +tests/run.luau Lune orchestrator (build -> run -> render -> upload) +tests/Entry.luau run-in-roblox entry script (starts Run mode, relays results) +tests/src/Harness.server Server bootstrap; runs the suite and publishes JSON +tests/src/Framework Tiny assertion/runner library +tests/src/FunctionalTests Functional test cases +tests/src/Benchmarks Benchmark matrix +tests/src/TestEventsModule FastCastEventsModule used by the parallel event test +``` + +`tests.project.json` mounts `src/` into `ReplicatedStorage` and `tests/src/` +into `ServerScriptService`, so `rojo build tests.project.json` produces the +place used by the runner. diff --git a/tests/hotpaths/Entry.luau b/tests/hotpaths/Entry.luau new file mode 100644 index 00000000..1a09b5cb --- /dev/null +++ b/tests/hotpaths/Entry.luau @@ -0,0 +1,7 @@ +local storage = game:GetService("ReplicatedStorage") +game:GetService("RunService"):Run() +local deadline = os.clock() + 180 +while storage:GetAttribute("HotPathResult") == nil and os.clock() < deadline do + task.wait(0.2) +end +print(storage:GetAttribute("HotPathResult") or "HOTPATH_TIMEOUT") diff --git a/tests/hotpaths/Harness.server.luau b/tests/hotpaths/Harness.server.luau new file mode 100644 index 00000000..6741dc07 --- /dev/null +++ b/tests/hotpaths/Harness.server.luau @@ -0,0 +1,238 @@ +--!native +--!optimize 2 +local storage = game:GetService("ReplicatedStorage") +local modules = storage.Modules +local Signal = require(modules.Signal) + +local function near(a, b, tolerance) + assert((a - b).Magnitude <= tolerance, tostring(a) .. " ~= " .. tostring(b)) +end + +-- Simulation events are Signals; wrap plain handler functions so the harness +-- stays readable. +local function makeEvents(handlers: { [string]: ((...any) -> ())? }): { [string]: any } + local events = {} + for name, handler in handlers do + local signal = Signal.new() + if handler then + signal:Connect(handler) + end + events[name] = signal + end + return events +end + +local function queueTests(simulation) + local hooks = simulation._Test + -- Cross several growth boundaries; f64 IDs must survive beyond u32. + for i = 1, 2057 do + hooks.Queue(2 ^ 40 + i, i % 4 + 1, i, nil, Vector3.one, nil, "tail") + end + for i = 1, 2057 do + local id, kind, args = hooks.Peek(i) + assert(id == 2 ^ 40 + i and kind == i % 4 + 1, "event metadata corrupted during growth") + assert( + args[1] == i and args[2] == nil and args[3] == Vector3.one and args[5] == "tail", + "event payload corrupted" + ) + end + hooks.Reset() + for i = 1, 2057 do + hooks.Queue(i, 4) + end + for i = 1, 2057 do + local id, kind, args = hooks.Peek(i) + assert(id == i and kind == 4 and next(args) == nil, "stale event arguments") + end + hooks.Reset() +end + +local function setup(simulation, count, events, world, origin, velocity, acceleration) + local actives = {} + local base = { + _TerminateCast = function(id) + actives[id] = nil + end, + } + simulation.Init(base, actives, events) + for i = 1, count do + local cast = { + ID = 2 ^ 40 + i, + StateInfo = { + TotalRuntime = 0, + HighFidelityBehavior = 0, + HighFidelitySegmentSize = 1, + FastCastEventsConfig = { + UseLengthChanged = events.LengthChanged ~= nil, + UseHit = events.Hit ~= nil, + }, + Trajectory = { Origin = origin, InitialVelocity = velocity, Acceleration = acceleration, StartTime = 0 }, + }, + RayInfo = { WorldRoot = world, MaxDistance = 1e30, Parameters = RaycastParams.new() }, + CastVariant = { CastType = require(modules.FastCastEnums).CastType.Raycast }, + } + actives[cast.ID] = cast + simulation.Register(cast) + end + return actives +end + +local function fresh(name) + local clone = modules[name]:Clone() + clone.Parent = modules + return require(clone) +end + +local function correctness() + queueTests(fresh("Optimized")) + queueTests(fresh("Parallel")) + local sim = fresh("Optimized") + local origin, velocity, acceleration = Vector3.new(10, 20, 30), Vector3.new(80, 90, -100), Vector3.new(0, -196.2, 0) + local observedOrigin, observedDirection, observedVelocity + local callbacks = 0 + local actives = setup(sim, 600, makeEvents({ + LengthChanged = function(_, _, _, _, v) + callbacks += 1 + observedVelocity = v + end, + }), { + Raycast = function(_, o, d) + observedOrigin, observedDirection = o, d + end, + }, origin, velocity, acceleration) + local elapsed = 0 + for _, dt in { 0, 1 / 240, 1 / 60, 0.1, 0.5 } do + sim._Test.Step(dt) + if dt > 0 then + near(observedOrigin, origin + velocity * elapsed + acceleration * (elapsed * elapsed * 0.5), 0.0001) + near(observedDirection, (velocity + acceleration * (elapsed + dt * 0.5)) * dt, 0.0001) + near(observedVelocity, velocity + acceleration * (elapsed + dt), 0.0001) + local reference = CFrame.new(observedOrigin, observedOrigin + observedDirection) + * CFrame.new(0, 0, -observedDirection.Magnitude / 2) + local cast = actives[2 ^ 40 + 1] + near(cast.CFrame.Position, reference.Position, 0.0001) + near(cast.CFrame.LookVector, reference.LookVector, 0.0001) + near(cast.CFrame.RightVector, reference.RightVector, 0.0001) + near(cast.CFrame.UpVector, reference.UpVector, 0.0001) + end + elapsed += dt + end + assert(callbacks == 2400, "event growth/order lost callbacks") + -- Dense active-list swap after removal must not mix event IDs or state. + sim.Unregister(2 ^ 40 + 1) + actives[2 ^ 40 + 1] = nil + sim._Test.Step(1 / 60) + assert(callbacks == 2999, "swap removal lost or duplicated events") + -- Tiny motion at a large origin must retain a finite direction. + local far = fresh("Optimized") + local direction + local casts = setup(far, 1, {}, { + Raycast = function(_, _, d) + direction = d + end, + }, Vector3.new(1e8, 1e8, 1e8), Vector3.new(1, 0, 0), Vector3.zero) + far._Test.Step(1 / 60) + near(direction, Vector3.new(1 / 60, 0, 0), 1e-7) + near(casts[2 ^ 40 + 1].CFrame.LookVector, Vector3.xAxis, 1e-6) + -- Vertical directions use the engine's lookAlong fallback basis. + for _, v in { Vector3.yAxis, -Vector3.yAxis, Vector3.zero } do + local vertical = fresh("Optimized") + local c = setup(vertical, 1, {}, { Raycast = function() end }, Vector3.zero, v, Vector3.zero) + vertical._Test.Step(1) + local cf = c[2 ^ 40 + 1].CFrame + assert(cf.Position.X == cf.Position.X and cf.LookVector.X == cf.LookVector.X, "non-finite CFrame") + if v ~= Vector3.zero then + near(cf.LookVector, v, 1e-6) + end + end +end + +local function collisionTests() + local wall = Instance.new("Part") + wall.Anchored = true + wall.Size = Vector3.new(50, 50, 2) + wall.Position = Vector3.new(0, 10000, -40) + wall.Parent = workspace + task.wait() + for _, kind in { "Raycast", "Blockcast", "Spherecast" } do + local sim = fresh("Optimized") + local hits = 0 + local casts = setup(sim, 96, makeEvents({ + Hit = function(_, result) + assert(result.Instance == wall, "wrong collision target") + hits += 1 + end, + }), workspace, Vector3.new(0, 10000, 0), Vector3.new(0, 0, -120), Vector3.zero) + local random = Random.new(42) + for _, cast in casts do + cast.StateInfo.Trajectory.Origin += Vector3.new(0, 0, random:NextNumber(-5, 5)) + cast.CastVariant.CastType = require(modules.FastCastEnums).CastType[kind] + cast.CastVariant.Size = Vector3.one + cast.CastVariant.Radius = 0.5 + cast.RayInfo.Parameters.FilterType = Enum.RaycastFilterType.Include + cast.RayInfo.Parameters.FilterDescendantsInstances = { wall } + end + for _ = 1, 60 do + sim._Test.Step(random:NextNumber(1 / 240, 1 / 20)) + end + assert(hits == 96, kind .. " missed collisions: " .. hits .. "/96") + end + wall:Destroy() +end + +local function benchmark() + if not modules:FindFirstChild("Baseline") then + return + end + for _, withEvents in { false, true } do + local variants = {} + for _, name in { "Baseline", "Optimized" } do + local sim = fresh(name) + setup( + sim, + 6000, + if withEvents then makeEvents({ LengthChanged = function() end }) else makeEvents({}), + workspace, + Vector3.new(0, 100000, 0), + Vector3.new(100, 10, -50), + Vector3.new(0, -10, 0) + ) + for _ = 1, 20 do + sim._Test.Step(1 / 60) + end + variants[name] = { sim = sim, samples = {} } + end + for round = 1, 9 do + -- Alternate order to reduce warmup / thermal bias. + for _, name in if round % 2 == 0 then { "Optimized", "Baseline" } else { "Baseline", "Optimized" } do + local variant = variants[name] + local started = os.clock() + for _ = 1, 60 do + variant.sim._Test.Step(1 / 60) + end + table.insert(variant.samples, (os.clock() - started) * 1000 / 60) + end + task.wait() + end + for name, variant in variants do + table.sort(variant.samples) + print( + string.format( + "HOTPATH_BENCH %s events=%s casts=6000 median_step_ms=%.4f min=%.4f max=%.4f", + name, + tostring(withEvents), + variant.samples[5], + variant.samples[1], + variant.samples[9] + ) + ) + end + end +end + +local ok, err = xpcall(function() + correctness() + collisionTests() + benchmark() +end, debug.traceback) +storage:SetAttribute("HotPathResult", if ok then "HOTPATH_PASS" else "HOTPATH_FAIL " .. tostring(err)) diff --git a/tests/hotpaths/README.md b/tests/hotpaths/README.md new file mode 100644 index 00000000..0e27702d --- /dev/null +++ b/tests/hotpaths/README.md @@ -0,0 +1,63 @@ +# Simulation hot paths + +Run from the repository root with the toolchain on PATH: + +```sh +npm run test:hotpaths +# Optional A/B benchmark against a saved, pre-change simulation module: +lune run tests/hotpaths/run.luau path/to/baseline-directory +``` + +The baseline directory must contain `SerialSimulation.luau`. Save that file +before making changes to compare against the exact workspace state. Both +versions use the current Config and dependencies. Nothing is uploaded. + +The runner builds an isolated Studio place under `build/hotpaths`. It injects +private test hooks into disposable copies of the actual simulation modules; +the shipped modules gain no test API. The ordinary functional suite remains +`npm test -- --no-upload`. + +Regression checks cover both queues crossing several buffer growth boundaries, +IDs above 32 bits, nil holes and reuse with shorter payloads, event dispatch +after active-list removal, accelerated trajectories, midpoint position and +orientation, zero/vertical movement, and tiny motion at large coordinates. +It also checks 96 real collisions per cast type with randomized timestep +lengths and starting positions. + +The optional benchmark directly times serial simulation steps with 6,000 +headless, accelerated raycasts in an empty world, using sparse IDs above 2^40. +It includes real workspace raycast calls and public-state writeback, excludes +creation and cosmetic movement, and compares LengthChanged off/on. Each +variant gets 20 warmup steps, followed by nine alternating-order samples of +60 steps at dt=1/60. Reported times are the median of those per-step averages, +not frame intervals or per-frame tail latency. It does not measure parallel +scheduler throughput, collision-heavy workloads, or SwiftCast. + +Local Studio run on 2026-09-17, against the workspace snapshot immediately +before this optimization pass (including its existing performance edits): + +| LengthChanged | Before ms/step | After ms/step | Time reduction | +| --- | ---: | ---: | ---: | +| Off | 13.3614 | 11.3702 | 14.9% | +| On | 15.9684 | 12.9217 | 19.1% | + +These are one-machine measurements, not universal speed guarantees. Existing +Heartbeat benchmarks remained around the 60 FPS cap in both versions. +The final full-suite run passed all 19 tests. One intermediate run timed out +on the serial spherecast wall test; that failure did not reproduce in the +final run or the 288 deterministic collision checks. Its cause remains +unresolved. + +The implementation stores event metadata in nine bytes per capacity slot +(f64 ID and u8 event kind), removing one three-field table per queued event +and the two arrays of references to those tables. Argument tables remain +pooled because they contain Roblox values. Capacity doubles at growth +boundaries and is reused thereafter. Five direct argument assignments avoid +sparse-table length ambiguity and vararg copy loops. + +Ballistic displacement uses `(v0 + a*(t0+t1)/2)*(t1-t0)`, algebraically equal +to subtracting the two trajectory positions, without rounding large world +coordinates before subtraction. Each moving cast builds one midpoint +`CFrame.lookAlong` instead of constructing and multiplying two CFrames. +Floating-point results can differ slightly from the previous implementation; +the large-origin test specifically verifies the improved displacement. diff --git a/tests/hotpaths/run.luau b/tests/hotpaths/run.luau new file mode 100644 index 00000000..7494fade --- /dev/null +++ b/tests/hotpaths/run.luau @@ -0,0 +1,69 @@ +-- Run from the repository root: lune run tests/hotpaths/run.luau [baseline-directory] +-- The optional directory contains the pre-change SerialSimulation.luau. +-- Hooks are injected into disposable build copies, never the public API. +local fs = require("@lune/fs") +local process = require("@lune/process") +local serde = require("@lune/serde") + +local build = "build/hotpaths" +fs.writeDir(build) +local modules = { ["$path"] = "../../src" } +local function instrument(name, path, simulation) + local source = fs.readFile(path) + local peek = if string.find(source, "local eventMetadata", 1, true) + then "local o = (i-1)*EVENT_STRIDE; return buffer.readf64(eventMetadata,o), buffer.readu8(eventMetadata,o+8), argsPool[i]" + else "local e = queuedEvents[i]; return e[1], e[2], e[3]" + local hooks = simulation + .. [[._Test = { + Step = UpdateCasts, + Queue = QueueEvent, + Flush = FireQueuedEvents, + Reset = function() ResetPools(); queuedEventsCount = 0 end, + Peek = function(i) ]] + .. peek + .. [[ end, + } + return ]] + .. simulation + local result, replacements = string.gsub(source, "return " .. simulation .. "%s*$", function() + return hooks + end) + assert(replacements == 1, "simulation return not found") + fs.writeFile(build .. "/" .. name .. ".luau", result) + modules[name] = { ["$path"] = name .. ".luau" } +end +instrument("Optimized", "src/SerialSimulation.luau", "SerialSimulation") +instrument("Parallel", "src/ParallelSimulation.luau", "ParallelSimulation") +if process.args[1] then + instrument("Baseline", process.args[1] .. "/SerialSimulation.luau", "SerialSimulation") +end +fs.writeFile( + build .. "/default.project.json", + serde.encode("json", { + name = "FastCastHotPaths", + tree = { + ["$className"] = "DataModel", + ReplicatedStorage = { Modules = modules }, + ServerScriptService = { HotPaths = { ["$path"] = "../../tests/hotpaths/Harness.server.luau" } }, + }, + }) +) +local function run(command, args) + local result = process.exec(command, args) + local output = result.stdout .. "\n" .. result.stderr + print(output) + -- Studio plugins can make run-in-roblox exit nonzero even when the + -- isolated harness succeeds. Its explicit sentinel is authoritative, + -- as in tests/run.luau; missing or failing sentinels still fail below. + if not result.ok and not (command == "run-in-roblox" and string.find(output, "HOTPATH_PASS", 1, true)) then + warn(result.stderr) + process.exit(1) + end + return output +end +run("rojo", { "build", build .. "/default.project.json", "-o", build .. "/test.rbxlx" }) +local output = run("run-in-roblox", { "--place", build .. "/test.rbxlx", "--script", "tests/hotpaths/Entry.luau" }) +if not string.find(output, "HOTPATH_PASS", 1, true) then + warn("hot path tests failed or timed out") + process.exit(1) +end diff --git a/tests/run.luau b/tests/run.luau new file mode 100644 index 00000000..8a14bcb3 --- /dev/null +++ b/tests/run.luau @@ -0,0 +1,321 @@ +--[[ + FastCast2 test runner. + + The single command that: + 1. builds the test place with rojo, + 2. runs it inside Roblox Studio with run-in-roblox, + 3. renders the functional-test summary and benchmark table as Markdown, + 4. uploads that report to paste.shellworks.dev and prints the URL. + + Usage (from the repository root): + lune run tests/run.luau + npm test + + Flags: + --no-upload Skip the paste.shellworks.dev upload. + --keep Keep the built place in ./build. +]] + +local fs = require("@lune/fs") +local net = require("@lune/net") +local process = require("@lune/process") +local serde = require("@lune/serde") + +local SENTINEL = "FASTCAST_SUITE_RESULTS" +local PASTE_ENDPOINT = "https://paste.shellworks.dev/api/pastes" + +-- Resolve the repository root from this file's location rather than the cwd, +-- so the runner works from anywhere. +local function resolveRoot(): string + local source = string.gsub(debug.info(1, "s"), "\\", "/") + local scriptDir = string.match(source, "^(.*)/[^/]+$") or "." + local base = string.match(scriptDir, "([^/]+)$") or scriptDir + + if base == "tests" then + return string.match(scriptDir, "^(.*)/tests$") or "." + end + + return scriptDir +end + +local ROOT = resolveRoot() +local PROJECT_FILE = ROOT .. "/tests.project.json" +local BUILD_DIR = ROOT .. "/build" +local PLACE_FILE = BUILD_DIR .. "/FastCast2Tests.rbxlx" +local ENTRY_FILE = ROOT .. "/tests/Entry.luau" + +local flags: { [string]: any } = {} +for _, value in process.args do + local key, raw = string.match(value, "^%-%-([%w%-]+)=?(.*)$") + if key then + flags[key] = if raw ~= "" then raw else true + end +end + +local function fail(message: string) + warn("[run] " .. message) + process.exit(1) +end + +local function run(program: string, args: { string }) + local ok, result = pcall(function() + return process.exec(program, args, { cwd = ROOT }) + end) + + if not ok then + return nil, tostring(result) + end + + return result, nil +end + +local function formatMs(value: any): string + if type(value) ~= "number" then + return "n/a" + end + return string.format("%.2f ms", value) +end + +local function benchmarkTable(rows: { any }): string + local lines = { + "| Mode | Cast | Create | Avg frame | P95 | P99 | Cleanup |", + "| --- | --- | --- | --- | --- | --- | --- |", + } + + for _, row in rows do + table.insert( + lines, + string.format( + "| %s | %s | %s | %s | %s | %s | %s |", + row.mode, + row.castType, + formatMs(row.creationMs), + formatMs(row.averageFrameMs), + formatMs(row.p95FrameMs), + formatMs(row.p99FrameMs), + formatMs(row.cleanupMs) + ) + ) + end + + return table.concat(lines, "\n") +end + +local function testSection(tests: any): string + local lines = { + string.format( + "**%d passed, %d failed** (%d total, %.1f ms)", + tests.passed or 0, + tests.failed or 0, + tests.total or 0, + tests.durationMs or 0 + ), + } + + if (tests.failed or 0) > 0 then + table.insert(lines, "") + table.insert(lines, "| Group | Case | Error |") + table.insert(lines, "| --- | --- | --- |") + + for _, group in tests.groups or {} do + for _, case in group.cases or {} do + if case.status ~= "passed" then + local message = string.gsub(case.error or "unknown error", "|", "\\|") + message = string.gsub(message, "[\r\n]+", " ") + table.insert( + lines, + string.format("| %s | %s | %s |", group.name, case.name, message) + ) + end + end + end + end + + return table.concat(lines, "\n") +end + +local function buildMarkdown(payload: any): string + local tests = payload.tests or { passed = 0, failed = 0, total = 0, durationMs = 0, groups = {} } + local benchmarks = payload.benchmarks or {} + + local parts = { + "# FastCast2 test report", + "", + "Generated: " .. (payload.generatedAt or "unknown"), + "Context: " .. (payload.runContext or "unknown"), + "", + "## Functional tests", + "", + testSection(tests), + "", + "## Benchmarks", + "", + benchmarkTable(benchmarks), + } + + if payload.benchmarkError then + table.insert(parts, "") + table.insert(parts, "```") + table.insert(parts, tostring(payload.benchmarkError)) + table.insert(parts, "```") + end + + if payload.error then + table.insert(parts, "") + table.insert(parts, "## Harness error") + table.insert(parts, "") + table.insert(parts, "```") + table.insert(parts, tostring(payload.error)) + table.insert(parts, "```") + end + + return table.concat(parts, "\n") .. "\n" +end + +local function parseResults(output: string): any? + for line in string.gmatch(output, "[^\r\n]+") do + local start = string.find(line, SENTINEL, 1, true) + if start then + local json = string.gsub(string.sub(line, start + #SENTINEL), "^%s+", "") + local ok, decoded = pcall(serde.decode, "json", json) + if ok and decoded then + return decoded + end + end + end + + return nil +end + +local function upload(markdown: string): (string?, string?) + local ok, response = pcall(function() + return net.request({ + url = PASTE_ENDPOINT, + method = "POST", + headers = { ["Content-Type"] = "application/json" }, + body = serde.encode("json", { + content = markdown, + title = "FastCast2 test report", + language = "markdown", + visibility = "unlisted", + expires_in = "2w", + }), + }) + end) + + if not ok then + return nil, tostring(response) + end + + if response.statusCode < 200 or response.statusCode >= 300 then + return nil, string.format("HTTP %d: %s", response.statusCode, response.body) + end + + local decoded = serde.decode("json", response.body) + return decoded.url, nil +end + +-- 1. Validate the project layout ------------------------------------------------- +if not fs.isFile(PROJECT_FILE) then + fail("could not find tests.project.json at " .. PROJECT_FILE) +end + +if not fs.isFile(ENTRY_FILE) then + fail("could not find tests/Entry.luau at " .. ENTRY_FILE) +end + +-- 2. Build the place ------------------------------------------------------------- +print("[run] building test place with rojo...") +if not fs.isDir(BUILD_DIR) then + fs.writeDir(BUILD_DIR) +end + +local buildResult, buildError = run("rojo", { "build", "tests.project.json", "-o", PLACE_FILE }) +if not buildResult then + fail( + "could not run rojo (" .. buildError .. "). Install it with `rokit install` or `cargo install rojo`." + ) +end + +if not buildResult.ok then + fail("rojo build failed:\n" .. buildResult.stdout .. buildResult.stderr) +end + +-- 3. Run the suite in Studio ----------------------------------------------------- +print("[run] launching Roblox Studio with run-in-roblox (this can take a minute)...") + +local runResult, runError = run( + "run-in-roblox", + { "--place", PLACE_FILE, "--script", ENTRY_FILE } +) + +if not runResult then + fail( + "could not run run-in-roblox (" + .. runError + .. "). Install it with `rokit install` or `cargo install run-in-roblox`, and make sure Roblox Studio is signed in." + ) +end + +local output = (runResult.stdout or "") .. "\n" .. (runResult.stderr or "") +local payload = parseResults(output) + +if not payload then + fail( + "no " .. SENTINEL .. " record found in run-in-roblox output.\n" + .. string.format("run-in-roblox exited with code %s.\n", tostring(runResult.code)) + .. "---- captured output (tail) ----\n" + .. string.sub(output, -4000) + ) +end + +-- 4. Report ---------------------------------------------------------------------- +local markdown = buildMarkdown(payload) +local tests = payload.tests or { passed = 0, failed = 0, total = 0, durationMs = 0, groups = {} } + +print("") +print(benchmarkTable(payload.benchmarks or {})) +print("") +print(string.format("Tests: %d passed, %d failed", tests.passed or 0, tests.failed or 0)) + +if (tests.failed or 0) > 0 then + for _, group in tests.groups or {} do + for _, case in group.cases or {} do + if case.status ~= "passed" then + print(string.format("FAIL %s / %s: %s", group.name, case.name, case.error or "unknown error")) + end + end + end +end + +if payload.error then + print("Harness error: " .. tostring(payload.error)) +end + +if payload.benchmarkError then + print("Benchmark error: " .. tostring(payload.benchmarkError)) +end + +if flags["no-upload"] then + print("Upload: skipped (--no-upload)") +else + print("[run] uploading report to " .. PASTE_ENDPOINT .. "...") + local url, uploadError = upload(markdown) + if url then + print("Report: " .. url) + else + warn("[run] upload failed: " .. tostring(uploadError)) + end +end + +if not flags.keep then + pcall(function() + fs.removeDir(BUILD_DIR) + end) +end + +if payload.error or (tests.failed or 0) > 0 then + process.exit(1) +end + +print("OK") diff --git a/tests/src/Benchmarks.luau b/tests/src/Benchmarks.luau new file mode 100644 index 00000000..6b4c7be3 --- /dev/null +++ b/tests/src/Benchmarks.luau @@ -0,0 +1,214 @@ +--[[ + FastCast2 automated benchmark matrix. + + Runs a fixed set of (mode x cast type) cases on the server and returns one + record per case. Creation and cleanup throughput are measured with + os.clock, while simulation cost is reported as Heartbeat frame-time + percentiles. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local ServerScriptService = game:GetService("ServerScriptService") + +local FastCast = require(ReplicatedStorage:WaitForChild("FastCast2")) + +local Benchmarks = {} + +Benchmarks.DefaultConfig = { + projectileCount = 500, + sampleDuration = 4, + parallelWorkers = 4, + randomSeed = 2026, + velocity = 6, + castTypes = { "Raycast", "Blockcast", "Spherecast" }, +} + +local frameTimes = {} +local isMeasuringFrames = false +local parallelCounter = 0 + +RunService.Heartbeat:Connect(function(deltaTime) + if isMeasuringFrames and deltaTime > 0 then + table.insert(frameTimes, deltaTime * 1000) + end +end) + +local function percentile(sortedValues: { number }, fraction: number): number + if #sortedValues == 0 then + return 0 + end + return sortedValues[math.max(1, math.ceil(#sortedValues * fraction))] +end + +local function summarizeFrames() + if #frameTimes == 0 then + return { frames = 0, averageMs = 0, p50Ms = 0, p95Ms = 0, p99Ms = 0 } + end + + local sorted = table.clone(frameTimes) + table.sort(sorted) + + local total = 0 + for _, frameTime in frameTimes do + total += frameTime + end + + return { + frames = #frameTimes, + averageMs = total / #frameTimes, + p50Ms = percentile(sorted, 0.50), + p95Ms = percentile(sorted, 0.95), + p99Ms = percentile(sorted, 0.99), + } +end + +local function makeInput(random: Random): (Vector3, Vector3) + local origin = Vector3.new( + random:NextInteger(-100, 100), + random:NextInteger(0, 100), + random:NextInteger(-100, 100) + ) + local direction = Vector3.new( + random:NextNumber(-1, 1), + random:NextNumber(-1, 1), + random:NextNumber(-1, 1) + ) + + if direction.Magnitude == 0 then + direction = Vector3.xAxis + else + direction = direction.Unit + end + + return origin, direction +end + +local function fire(caster: any, castType: string, origin: Vector3, direction: Vector3, velocity: number, behavior: any) + if castType == "Blockcast" then + caster:BlockcastFire(origin, Vector3.one, direction, velocity, behavior) + elseif castType == "Spherecast" then + caster:SpherecastFire(origin, 0.5, direction, velocity, behavior) + else + caster:RaycastFire(origin, direction, velocity, behavior) + end +end + +local function createCaster(mode: string, config: any, activeCasts: { any }): any + local caster + + if mode == "Parallel" then + parallelCounter += 1 + caster = FastCast.newParallel() + caster:Init( + config.parallelWorkers, + "BulkMoveTo" + ) + else + caster = FastCast.new() + caster:Init("BulkMoveTo") + end + + caster.CastFire = function(cast: any) + table.insert(activeCasts, cast) + end + + return caster +end + +local function runCase(mode: string, castType: string, caster: any, activeCasts: { any }, config: any) + local random = Random.new(config.randomSeed) + local creationStarted = os.clock() + + for _ = 1, config.projectileCount do + local origin, direction = makeInput(random) + fire(caster, castType, origin, direction, config.velocity, config.behavior) + end + + local creationSeconds = os.clock() - creationStarted + + -- Let parallel workers and CastFire callbacks finish before sampling. + task.wait() + table.clear(frameTimes) + isMeasuringFrames = true + task.wait(config.sampleDuration) + isMeasuringFrames = false + + local frames = summarizeFrames() + local activeCount = #activeCasts + local cleanupStarted = os.clock() + + for index = #activeCasts, 1, -1 do + FastCast:TerminateCast(activeCasts[index]) + activeCasts[index] = nil + end + + local cleanupSeconds = os.clock() - cleanupStarted + + local result = { + mode = mode, + castType = castType, + projectiles = config.projectileCount, + activeCasts = activeCount, + creationMs = creationSeconds * 1000, + creationPerSecond = if creationSeconds > 0 then config.projectileCount / creationSeconds else 0, + cleanupMs = cleanupSeconds * 1000, + cleanupPerSecond = if cleanupSeconds > 0 then activeCount / cleanupSeconds else 0, + frames = frames.frames, + averageFrameMs = frames.averageMs, + p50FrameMs = frames.p50Ms, + p95FrameMs = frames.p95Ms, + p99FrameMs = frames.p99Ms, + } + + print(string.format( + "[bench] %s/%s: create %.2f ms, avg %.2f ms, p95 %.2f ms, p99 %.2f ms, cleanup %.2f ms", + mode, + castType, + result.creationMs, + result.averageFrameMs, + result.p95FrameMs, + result.p99FrameMs, + result.cleanupMs + )) + + task.wait(1) + return result +end + +--[=[ + Runs the full benchmark matrix. + + @param overrides any? -- Optional fields merged over DefaultConfig. + @return { any } -- One record per (mode, cast type) case. +]=] +function Benchmarks.run(overrides: any?): { any } + local config = table.clone(Benchmarks.DefaultConfig) + for key, value in overrides or {} do + config[key] = value + end + + local behavior = FastCast.newBehavior() + behavior.MaxDistance = 1e9 + behavior.Acceleration = Vector3.zero + behavior.AutoIgnoreContainer = true + config.behavior = behavior + + local results = {} + + for _, mode in { "Serial", "Parallel" } do + local activeCasts = {} + local caster = createCaster(mode, config, activeCasts) + + for _, castType in config.castTypes do + table.insert(results, runCase(mode, castType, caster, activeCasts, config)) + end + + caster:Destroy() + task.wait(1) + end + + return results +end + +return Benchmarks diff --git a/tests/src/Framework.luau b/tests/src/Framework.luau new file mode 100644 index 00000000..9d413eb1 --- /dev/null +++ b/tests/src/Framework.luau @@ -0,0 +1,268 @@ +--[[ + FastCast2 test framework. + + A small, dependency-free assertion and runner library. Tests are grouped, + run sequentially, and any raised error marks a case as failed. Tests may + yield (task.wait), which is required for cast simulation timing. +]] + +local Framework = {} + +export type TestFunction = (expect: any) -> () +export type TestCase = { name: string, fn: TestFunction } +export type TestGroup = { name: string, cases: { TestCase } } + +local Assert = {} +Framework.Assert = Assert + +local function describeValue(value: any): string + if typeof(value) == "Instance" then + return value:GetFullName() + end + return tostring(value) +end + +--[=[ + @class Assert + Assertion helpers passed to every test case as `expect`. +]=] + +function Assert.truthy(condition: any, message: string?) + if not condition then + error(message or "expected value to be truthy", 2) + end +end + +function Assert.falsy(condition: any, message: string?) + if condition then + error(message or "expected value to be falsy", 2) + end +end + +function Assert.equal(actual: any, expected: any, message: string?) + if actual ~= expected then + error( + string.format( + "%s (expected %s, got %s)", + message or "values are not equal", + describeValue(expected), + describeValue(actual) + ), + 2 + ) + end +end + +function Assert.notEqual(actual: any, expected: any, message: string?) + if actual == expected then + error( + string.format( + "%s (did not expect %s)", + message or "values should not be equal", + describeValue(expected) + ), + 2 + ) + end +end + +function Assert.near(actual: any, expected: any, tolerance: number?, message: string?) + tolerance = tolerance or 1e-4 + + local actualType = typeof(actual) + if actualType ~= typeof(expected) then + error("near() expects values of the same type", 2) + end + + local delta + if actualType == "Vector3" then + delta = (actual - expected).Magnitude + elseif actualType == "number" then + delta = math.abs(actual - expected) + else + error("near() only supports numbers and Vector3", 2) + end + + if delta > tolerance then + error( + string.format( + "%s (expected %s, got %s, delta %.6f)", + message or "values are not near", + describeValue(expected), + describeValue(actual), + delta + ), + 2 + ) + end +end + +function Assert.instance(value: any, className: string, message: string?) + if typeof(value) ~= "Instance" then + error( + string.format( + "%s (expected an Instance, got %s)", + message or "value is not an Instance", + typeof(value) + ), + 2 + ) + end + + if not value:IsA(className) then + error( + string.format( + "%s (expected %s, got %s)", + message or "instance class mismatch", + className, + value.ClassName + ), + 2 + ) + end +end + +function Assert.greaterThan(actual: any, expected: any, message: string?) + if typeof(actual) ~= "number" or typeof(expected) ~= "number" then + error("greaterThan() expects numbers", 2) + end + + if actual <= expected then + error( + string.format( + "%s (expected > %s, got %s)", + message or "value is not greater than expected", + expected, + actual + ), + 2 + ) + end +end + +--[=[ + Builds a group of test cases. + + @param name string -- The group name. + @param register (test: (name: string, fn: TestFunction) -> ()) -> () -- Registration callback. + @return TestGroup +]=] +function Framework.describe(name: string, register: (test: (string, TestFunction) -> ()) -> ()): TestGroup + local group: TestGroup = { name = name, cases = {} } + + register(function(caseName: string, fn: TestFunction) + table.insert(group.cases, { name = caseName, fn = fn }) + end) + + return group +end + +--[=[ + Returns true once `predicate` returns truthy, or false if `timeout` elapses. + + @param predicate () -> boolean + @param timeout number? -- Seconds to wait. Defaults to 5. + @param interval number? -- Seconds between polls. Defaults to 0.05. + @return boolean +]=] +function Framework.waitFor(predicate: () -> boolean, timeout: number?, interval: number?): boolean + timeout = timeout or 5 + interval = interval or 0.05 + local deadline = os.clock() + timeout + + while true do + local ok, result = pcall(predicate) + if ok and result then + return true + end + + if os.clock() >= deadline then + return false + end + + task.wait(interval) + end +end + +--[=[ + Runs every group and returns a JSON-serializable report. Each test gets its + own `expect` object; resources registered through `expect.cleanup` are torn + down after the case finishes, even when it fails. +]=] +function Framework.run(groups: { TestGroup }, afterEach: (() -> ())?) + local report = { + passed = 0, + failed = 0, + total = 0, + durationMs = 0, + groups = {} :: { any }, + } + + local suiteStarted = os.clock() + + for _, group in groups do + local groupReport = { + name = group.name, + passed = 0, + failed = 0, + cases = {} :: { any }, + } + + print(string.format("[tests] %s", group.name)) + + for _, case in group.cases do + local cleanups = {} + local expect = setmetatable({ + cleanup = function(callback: () -> ()) + table.insert(cleanups, callback) + end, + }, { __index = Assert }) + + local caseStarted = os.clock() + local ok, err = pcall(case.fn, expect) + + for index = #cleanups, 1, -1 do + local cleaned, cleanupError = pcall(cleanups[index]) + if not cleaned then + warn(string.format(" cleanup for %s failed: %s", case.name, tostring(cleanupError))) + end + end + + if afterEach then + local hooked, hookError = pcall(afterEach) + if not hooked then + warn(string.format(" afterEach for %s failed: %s", case.name, tostring(hookError))) + end + end + + local durationMs = (os.clock() - caseStarted) * 1000 + + local caseReport = { + name = case.name, + status = if ok then "passed" else "failed", + durationMs = durationMs, + error = if ok then nil else tostring(err), + } + + report.total += 1 + if ok then + report.passed += 1 + groupReport.passed += 1 + print(string.format(" ok %s (%.1f ms)", case.name, durationMs)) + else + report.failed += 1 + groupReport.failed += 1 + warn(string.format(" FAIL %s (%.1f ms): %s", case.name, durationMs, tostring(err))) + end + + table.insert(groupReport.cases, caseReport) + end + + table.insert(report.groups, groupReport) + end + + report.durationMs = (os.clock() - suiteStarted) * 1000 + return report +end + +return Framework diff --git a/tests/src/FunctionalTests.luau b/tests/src/FunctionalTests.luau new file mode 100644 index 00000000..11d5bf81 --- /dev/null +++ b/tests/src/FunctionalTests.luau @@ -0,0 +1,589 @@ +--[[ + FastCast2 functional tests. + + These cover the checklist in TESTS.md: initialization, the three cast + variants, every public event, ObjectCache, movement modes, cast + manipulation, and the parallel-only extensions. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local ServerScriptService = game:GetService("ServerScriptService") + +local FastCast = require(ReplicatedStorage:WaitForChild("FastCast2")) +local FastCastEnums = require(ReplicatedStorage.FastCast2:WaitForChild("FastCastEnums")) + +local Framework = require(script.Parent.Framework) +local TestEventsModule = script.Parent:WaitForChild("TestEventsModule") + +local CAST_TYPES = { "Raycast", "Blockcast", "Spherecast" } +local HIT_TIMEOUT = 5 + +local arenaCounter = 0 + +local function createArena(): Folder + arenaCounter += 1 + local arena = Instance.new("Folder") + arena.Name = "FastCast2TestArena" .. arenaCounter + arena.Parent = workspace + return arena +end + +local function createWall(parent: Instance, position: Vector3, size: Vector3): Part + local wall = Instance.new("Part") + wall.Name = "Wall" + wall.Anchored = true + wall.CanCollide = true + wall.CanQuery = true + wall.Size = size + wall.Position = position + wall.Parent = parent + return wall +end + +local function createContainer(parent: Instance): Folder + local container = Instance.new("Folder") + container.Name = "Projectiles" + container.Parent = parent + return container +end + +local function makeBehavior(container: Instance): any + local behavior = FastCast.newBehavior() + behavior.MaxDistance = 1e6 + behavior.Acceleration = Vector3.zero + behavior.CosmeticBulletContainer = container + + local params = RaycastParams.new() + params.FilterType = Enum.RaycastFilterType.Exclude + params.IgnoreWater = true + params.FilterDescendantsInstances = { container } + behavior.RaycastParams = params + + return behavior +end + +local function fire(caster: any, castType: string, origin: Vector3, direction: Vector3, velocity: number, behavior: any) + if castType == "Blockcast" then + caster:BlockcastFire(origin, Vector3.one, direction, velocity, behavior) + elseif castType == "Spherecast" then + caster:SpherecastFire(origin, 0.5, direction, velocity, behavior) + else + caster:RaycastFire(origin, direction, velocity, behavior) + end +end + +local parallelCounter = 0 + +local function createParallelCaster(events: { [string]: any }?): any + parallelCounter += 1 + local caster = FastCast.newParallel() + + for name, fn in events or {} do + caster[name] = fn + end + + caster:Init( + 2, + "BulkMoveTo" + ) + + return caster +end + +local function registerSerialCastTypes(test: (string, any) -> ()) + for _, castType in CAST_TYPES do + test("Serial " .. castType .. " reaches a wall", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local hitInstance = nil + local terminated = false + + local caster = FastCast.new() + caster.Hit = function(_, result) + hitInstance = result.Instance + end + caster.CastTerminating = function() + terminated = true + end + caster:Init("BulkMoveTo") + + fire(caster, castType, Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return hitInstance ~= nil + end, HIT_TIMEOUT), + castType .. " never registered a hit" + ) + expect.equal(hitInstance, wall, castType .. " hit the wrong instance") + expect.truthy( + Framework.waitFor(function() + return terminated + end, HIT_TIMEOUT), + castType .. " never terminated after the hit" + ) + + caster:Destroy() + arena:Destroy() + end) + end +end + +local function registerParallelCastTypes(test: (string, any) -> ()) + for _, castType in CAST_TYPES do + test("Parallel " .. castType .. " reaches a wall", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local hitInstance = nil + local terminated = false + + local caster = createParallelCaster({ + Hit = function(_, result) + hitInstance = result.Instance + end, + CastTerminating = function() + terminated = true + end, + }) + + fire(caster, castType, Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return hitInstance ~= nil + end, HIT_TIMEOUT), + "Parallel " .. castType .. " never registered a hit" + ) + expect.equal(hitInstance, wall, "Parallel " .. castType .. " hit the wrong instance") + expect.truthy( + Framework.waitFor(function() + return terminated + end, HIT_TIMEOUT), + "Parallel " .. castType .. " never terminated after the hit" + ) + + caster:Destroy() + arena:Destroy() + end) + end +end + +return function() + return { + Framework.describe("FastCastBehavior", function(test) + test("newBehavior returns the documented defaults", function(expect) + local behavior = FastCast.newBehavior() + + expect.equal(behavior.MaxDistance, 1000, "MaxDistance default changed") + expect.near(behavior.Acceleration, Vector3.zero, 1e-6, "Acceleration default changed") + expect.equal( + behavior.HighFidelityBehavior, + FastCastEnums.HighFidelityBehavior.Default, + "HighFidelityBehavior default changed" + ) + expect.equal(behavior.AutoIgnoreContainer, true, "AutoIgnoreContainer default changed") + end) + end), + + Framework.describe("FastCastSerial", function(test) + test("initializes and destroys", function(expect) + local caster = FastCast.new() + caster:Init("BulkMoveTo") + + expect.equal(caster.AlreadyInit, true, "caster was not marked initialized") + expect.equal(caster.MovementMode, "BulkMoveTo", "movement mode was not stored") + + caster:Destroy() + expect.equal(getmetatable(caster), nil, "destroy did not clear the metatable") + end) + + test("RaycastFire fires CastFire synchronously", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firedCast = nil + local caster = FastCast.new() + caster.CastFire = function(cast) + firedCast = cast + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 60, behavior) + + expect.truthy(firedCast, "CastFire did not run") + expect.equal(firedCast.Type, FastCastEnums.CastType.Raycast, "wrong cast type") + expect.equal(firedCast.CFrame.Position, Vector3.new(0, 0, 0), "cast origin is wrong") + + FastCast:TerminateCast(firedCast) + caster:Destroy() + arena:Destroy() + end) + + registerSerialCastTypes(test) + + test("event callbacks assigned after Init still fire", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local caster = FastCast.new() + caster:Init("BulkMoveTo") + + local hitInstance = nil + caster.Hit = function(_, result) + hitInstance = result.Instance + end + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return hitInstance ~= nil + end, HIT_TIMEOUT), + "Hit callback assigned after Init never fired" + ) + expect.equal(hitInstance, wall, "Hit callback fired for the wrong instance") + + caster:Destroy() + arena:Destroy() + end) + + test("events are Signals that support multiple listeners and Disconnect", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firstCount = 0 + local secondCount = 0 + + local caster = FastCast.new() + local firstConnection = caster.Hit:Connect(function(_, result) + if result.Instance == wall then + firstCount += 1 + end + end) + caster.Hit:Connect(function(_, result) + if result.Instance == wall then + secondCount += 1 + end + end) + caster:Init("BulkMoveTo") + + -- A disconnected listener must not receive the event. + firstConnection:Disconnect() + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return secondCount > 0 + end, HIT_TIMEOUT), + "connected Signal listener never fired" + ) + expect.equal(firstCount, 0, "disconnected Signal listener still fired") + + caster:Destroy() + arena:Destroy() + end) + + test("Pierced and CanPierce run when a cast is allowed through", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + behavior.MaxDistance = 80 + + createWall(arena, Vector3.new(0, 0, -20), Vector3.new(50, 50, 2)) + + local piercedCount = 0 + local terminated = false + + local caster = FastCast.new() + caster.CanPierce = function() + return true + end + caster.Pierced = function() + piercedCount += 1 + end + caster.CastTerminating = function() + terminated = true + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return terminated + end, HIT_TIMEOUT), + "cast never terminated" + ) + expect.greaterThan(piercedCount, 0, "the cast never pierced the wall") + + caster:Destroy() + arena:Destroy() + end) + + test("LengthChanged runs when enabled", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + behavior.FastCastEventsConfig.UseLengthChanged = true + + local lengthChanges = 0 + local caster = FastCast.new() + caster.LengthChanged = function() + lengthChanges += 1 + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + Framework.waitFor(function() + return lengthChanges > 0 + end, HIT_TIMEOUT) + + expect.greaterThan(lengthChanges, 0, "LengthChanged never fired") + + caster:Destroy() + arena:Destroy() + end) + + test("ObjectCache reuses pooled projectile parts", function(expect) + local arena = createArena() + local holder = Instance.new("Folder") + holder.Name = "Cache" + holder.Parent = arena + + local template = Instance.new("Part") + template.Name = "Projectile" + template.Anchored = true + template.CanCollide = false + template.CanQuery = false + template.Parent = arena + + local behavior = FastCast.newBehavior() + behavior.MaxDistance = 1e6 + behavior.Acceleration = Vector3.zero + behavior.RaycastParams = RaycastParams.new() + + local firedCast = nil + local caster = FastCast.new() + caster.CastFire = function(cast) + firedCast = cast + end + caster:Init("BulkMoveTo", true, template, 8, holder) + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 1, 0), 60, behavior) + + expect.truthy(firedCast, "CastFire did not run") + local projectile = firedCast.RayInfo.CosmeticBulletObject + expect.instance(projectile, "BasePart", "ObjectCache did not provide a projectile") + expect.truthy( + projectile:IsDescendantOf(holder), + "projectile was not parented under the cache holder" + ) + + FastCast:TerminateCast(firedCast) + caster:Destroy() + arena:Destroy() + end) + + test("movement mode can be switched", function(expect) + local caster = FastCast.new() + caster:Init("Motor6D") + expect.equal(caster.MovementMode, "Motor6D", "initial movement mode was not applied") + + caster:SetMovementModeEnabled(true, "BulkMoveTo") + expect.equal(caster.MovementMode, "BulkMoveTo", "movement mode was not switched") + + caster:Destroy() + end) + + test("cast velocity, acceleration and position can be manipulated", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firedCast = nil + local caster = FastCast.new() + caster.CastFire = function(cast) + firedCast = cast + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 1, 0), 60, behavior) + expect.truthy(firedCast, "CastFire did not run") + + local cast = firedCast :: any + FastCast:SetVelocityCast(cast, Vector3.new(0, 0, -100)) + expect.near(FastCast:GetVelocityCast(cast), Vector3.new(0, 0, -100), 1e-3, "velocity was not set") + + FastCast:AddVelocityCast(cast, Vector3.new(10, 0, 0)) + expect.near(FastCast:GetVelocityCast(cast), Vector3.new(10, 0, -100), 1e-3, "velocity was not added") + + FastCast:SetAccelerationCast(cast, Vector3.new(0, -10, 0)) + expect.near(FastCast:GetAccelerationCast(cast), Vector3.new(0, -10, 0), 1e-3, "acceleration was not set") + + FastCast:AddAccelerationCast(cast, Vector3.new(0, -5, 0)) + expect.near(FastCast:GetAccelerationCast(cast), Vector3.new(0, -15, 0), 1e-3, "acceleration was not added") + + FastCast:SetPositionCast(cast, Vector3.new(5, 5, 5)) + expect.near(FastCast:GetPositionCast(cast), Vector3.new(5, 5, 5), 1e-3, "position was not set") + + FastCast:AddPositionCast(cast, Vector3.new(1, 0, 0)) + expect.near(FastCast:GetPositionCast(cast), Vector3.new(6, 5, 5), 1e-3, "position was not added") + + FastCast:TerminateCast(cast) + caster:Destroy() + arena:Destroy() + end) + + test("HighFidelity Always still resolves a hit", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + behavior.HighFidelityBehavior = FastCastEnums.HighFidelityBehavior.Always + behavior.HighFidelitySegmentSize = 1 + + local hitInstance = nil + local caster = FastCast.new() + caster.Hit = function(_, result) + hitInstance = result.Instance + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return hitInstance ~= nil + end, HIT_TIMEOUT), + "high fidelity cast never hit the wall" + ) + expect.equal(hitInstance, wall, "high fidelity cast hit the wrong instance") + + caster:Destroy() + arena:Destroy() + end) + end), + + Framework.describe("FastCastParallel", function(test) + test("initializes and destroys", function(expect) + local caster = createParallelCaster() + expect.equal(caster.AlreadyInit, true, "parallel caster was not marked initialized") + + caster:Destroy() + end) + + registerParallelCastTypes(test) + + test("SetFastCastEventsModule forwards module events", function(expect) + local arena = createArena() + createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local signal = ReplicatedStorage:WaitForChild("FastCast2TestSignal") :: BindableEvent + local received = {} + + local connection = signal.Event:Connect(function(name) + received[name] = true + end) + + local caster = createParallelCaster() + caster:SetFastCastEventsModule(TestEventsModule) + + -- Let the worker VMs receive the new events module. + task.wait(0.25) + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return received.Hit == true + end, HIT_TIMEOUT), + "events module Hit never fired" + ) + + connection:Disconnect() + caster:Destroy() + arena:Destroy() + end) + + test("events are Signals that support multiple listeners", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firstCount = 0 + local secondCount = 0 + + local caster = createParallelCaster() + caster.Hit:Connect(function(_, result) + if result.Instance == wall then + firstCount += 1 + end + end) + caster.Hit:Connect(function(_, result) + if result.Instance == wall then + secondCount += 1 + end + end) + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return firstCount > 0 and secondCount > 0 + end, HIT_TIMEOUT), + "parallel Signal listeners never fired" + ) + + caster:Destroy() + arena:Destroy() + end) + + test("SyncChangesToCast does not error", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firedCast = nil + local caster = createParallelCaster({ + CastFire = function(cast) + firedCast = cast + end, + }) + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 1, 0), 60, behavior) + expect.truthy( + Framework.waitFor(function() + return firedCast ~= nil + end, HIT_TIMEOUT), + "parallel CastFire never ran" + ) + + local cast = firedCast :: any + FastCast:SetPositionCast(cast, Vector3.new(10, 10, 10)) + caster:SyncChangesToCast(cast) + task.wait(0.1) + + expect.near(FastCast:GetPositionCast(cast), Vector3.new(10, 10, 10), 1e-3, "sync changed the local cast") + + FastCast:TerminateCast(cast) + caster:Destroy() + arena:Destroy() + end) + end), + } +end diff --git a/tests/src/Harness.server.luau b/tests/src/Harness.server.luau new file mode 100644 index 00000000..522b5fe2 --- /dev/null +++ b/tests/src/Harness.server.luau @@ -0,0 +1,110 @@ +--[[ + FastCast2 test harness (server). + + Runs the functional suite and the benchmark matrix, then publishes the + combined JSON report to a ReplicatedStorage attribute so the run-in-roblox + entry script can forward it to the Lune orchestrator. +]] + +local HttpService = game:GetService("HttpService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local ServerScriptService = game:GetService("ServerScriptService") + +if not RunService:IsRunning() then + return +end + +local RESULT_ATTRIBUTE = "FastCast2TestResults" + +local function ensureTestSignal() + if ReplicatedStorage:FindFirstChild("FastCast2TestSignal") == nil then + local signal = Instance.new("BindableEvent") + signal.Name = "FastCast2TestSignal" + signal.Parent = ReplicatedStorage + end +end + +-- Safety net: a failing test may abort before its own teardown, so scrub any +-- test arenas and worker containers between cases to keep them isolated. +local function cleanupLeftovers() + for _, child in workspace:GetChildren() do + if string.match(child.Name, "^FastCast2TestArena") then + child:Destroy() + end + end + + for _, child in ServerScriptService:GetChildren() do + if + string.match(child.Name, "^FastCast2TestContainers%d") + or string.match(child.Name, "^FastCast2TestVMs%d") + then + child:Destroy() + end + end +end + +local function runSuite() + local Framework = require(script.Parent.Framework) + local FunctionalTests = require(script.Parent.FunctionalTests) + local Benchmarks = require(script.Parent.Benchmarks) + + ensureTestSignal() + + local tests = Framework.run(FunctionalTests(), cleanupLeftovers) + + local benchmarks = {} + local benchmarkError = nil + local benchmarkOk, benchmarkResult = pcall(function() + return Benchmarks.run() + end) + + if benchmarkOk then + benchmarks = benchmarkResult + else + benchmarkError = tostring(benchmarkResult) + warn("[FastCast2] benchmark run failed: " .. benchmarkError) + end + + return { + generatedAt = os.date("%Y-%m-%d %H:%M:%S"), + runContext = if RunService:IsStudio() then "studio" else "game", + tests = tests, + benchmarks = benchmarks, + benchmarkError = benchmarkError, + } +end + +local ok, result = pcall(runSuite) + +local payload +if ok then + payload = result +else + warn("[FastCast2] suite crashed: " .. tostring(result)) + payload = { + generatedAt = os.date("%Y-%m-%d %H:%M:%S"), + runContext = if RunService:IsStudio() then "studio" else "game", + tests = { passed = 0, failed = 0, total = 0, durationMs = 0, groups = {} }, + benchmarks = {}, + error = tostring(result), + } +end + +local encoded = HttpService:JSONEncode(payload) +ReplicatedStorage:SetAttribute(RESULT_ATTRIBUTE, encoded) + +if payload.error then + warn("[FastCast2] published results with errors") +else + print(string.format( + "[FastCast2] suite complete: %d passed, %d failed, %d benchmark case(s)", + payload.tests.passed, + payload.tests.failed, + #payload.benchmarks + )) +end + +-- Also emit the sentinel directly so the runner can find it even if the +-- entry script's relay is unavailable. +print("FASTCAST_SUITE_RESULTS " .. encoded) diff --git a/tests/src/TestEventsModule.luau b/tests/src/TestEventsModule.luau new file mode 100644 index 00000000..65579d79 --- /dev/null +++ b/tests/src/TestEventsModule.luau @@ -0,0 +1,27 @@ +--[[ + FastCastEventsModule used by the parallel tests. + + It is required inside each worker Actor VM and forwards every event to a + BindableEvent in ReplicatedStorage so the main thread can observe it. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local signal = ReplicatedStorage:WaitForChild("FastCast2TestSignal") :: BindableEvent + +local function forward(name: string) + return function(...) + signal:Fire(name, ...) + end +end + +return { + CastFire = forward("CastFire"), + Hit = forward("Hit"), + Pierced = forward("Pierced"), + LengthChanged = forward("LengthChanged"), + CastTerminating = forward("CastTerminating"), + CanPierce = function() + return false + end, +}