working replay system

This commit is contained in:
2022-03-21 19:01:28 +02:00
parent 99637255da
commit 4997077978
17 changed files with 243 additions and 108 deletions

View File

@@ -1,7 +1,7 @@
{
"ID": "194e05f445ece24ec5448d886e1334df",
"TypeName": "FlaxEngine.SceneAsset",
"EngineBuild": 6226,
"EngineBuild": 6330,
"Data": [
{
"ID": "194e05f445ece24ec5448d886e1334df",
@@ -70,13 +70,13 @@
"Transform": {
"Translation": {
"X": 0.0,
"Y": 226.0,
"Y": 716.0,
"Z": 0.0
}
},
"Control": "FlaxEngine.GUI.Label",
"Data": {
"Text": "eFPS: 61\nuFPS: 60\nrFPS: 60\npFPS: 30\nCon: NaNms\nDirectX11",
"Text": "eFPS: 120\nuFPS: 120\nrFPS: 120\npFPS: 30",
"TextColor": {
"R": 1.0,
"G": 1.0,
@@ -123,9 +123,9 @@
},
"Offsets": {
"Left": 0.0,
"Right": 71.0,
"Top": -562.0,
"Bottom": 96.0
"Right": 57.0,
"Top": -97.0,
"Bottom": 64.0
},
"Scale": {
"X": 1.0,
@@ -164,8 +164,8 @@
"Name": "ContainerControl 0",
"Transform": {
"Translation": {
"X": 45849.0,
"Y": -12.0,
"X": 45644.0,
"Y": 0.5,
"Z": 0.0
}
},

View File

@@ -1,7 +1,7 @@
{
"ID": "4bd8a4cc460399b5f1975fbe0a668e3f",
"TypeName": "FlaxEditor.Content.Settings.PhysicsSettings",
"EngineBuild": 6226,
"EngineBuild": 6330,
"Data": {
"DefaultGravity": {
"X": 0.0,
@@ -17,8 +17,8 @@
"EnableAdaptiveForce": false,
"MaxDeltaTime": 0.1,
"EnableSubstepping": false,
"SubstepDeltaTime": 0.008333334,
"MaxSubsteps": 5,
"SubstepDeltaTime": 0.001,
"MaxSubsteps": 60,
"SupportCookingAtRuntime": true,
"LayerMasks": [
4294967295,

View File

@@ -1,9 +1,9 @@
{
"ID": "a55dc3c04da4ea3744b7f1994565beac",
"TypeName": "FlaxEditor.Content.Settings.TimeSettings",
"EngineBuild": 6226,
"EngineBuild": 6330,
"Data": {
"UpdateFPS": 120.0,
"UpdateFPS": 0.0,
"PhysicsFPS": 120.0,
"DrawFPS": 120.0,
"TimeScale": 1.0,

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -14,7 +14,7 @@
},
{
"name": "Flax",
"path": "C:\\dev\\Flax\\FlaxEngine"
"path": "C:\\dev\\Flax\\Flax_master"
}
]
}

View File

@@ -461,7 +461,9 @@ namespace Cabrito
else if (key == KeyboardKeys.PageUp)
{
ScrollOffset += GetHeightInLines() / 2;
var maxOffset = Console.Lines.Count - GetHeightInLines();
// should count the wrapped line count here over Console.Lines.Count
//var maxOffset = Console.Lines.Count - GetHeightInLines();
var maxOffset = Console.Lines.Count - 1;
if (ScrollOffset > maxOffset)
ScrollOffset = maxOffset;
}

View File

@@ -88,8 +88,7 @@ namespace Cabrito
sb.Append("\nuFPS: " + ((int) Math.Round(1.0f / updateTimeAvg)).ToString());
sb.Append("\nrFPS: " + ((int) Math.Round(1.0f / drawTimeAvg)).ToString());
sb.Append("\npFPS: " + ((int) Math.Round(1.0f / physicsTimeAvg)).ToString());
sb.Append("\nCon: " + conTime.ToString() + "ms");
sb.Append("\n" + currentRenderer);
//sb.Append("\nCon: " + conTime.ToString() + "ms");
//sb.Append("\nGC memory: " + (GC.GetTotalMemory(false) / 1000000.0f).ToString() + "MB");
//sb.Append("\nUpdate profiler: " + updateProfTime.ToString() + "ms");

View File

@@ -16,6 +16,10 @@ namespace Game
public float moveRight;
public bool attacking;
public bool jumping;
public Vector3 verificationPosition;
public Vector3 verificationVelocity;
public Quaternion verificationOrientation;
}
[StructLayout(LayoutKind.Sequential)]
@@ -36,7 +40,6 @@ namespace Game
public class PlayerInput
{
public PlayerState lastState;
public PlayerState currentState;
public ulong frame;
@@ -54,9 +57,8 @@ namespace Game
{
}
public void RecordCurrentActorState(PlayerActorState actorState)
public virtual void RecordCurrentActorState(PlayerActorState actorState)
{
currentState.actor = actorState;
}
public PlayerInputState GetCurrentInputState()

View File

@@ -35,7 +35,7 @@ namespace Game
{
int rawsize = Marshal.SizeOf(typeof(T));
if (rawsize > rawData.Length - position)
throw new ArgumentException("Not enough data to fill struct. Array length from position: "+(rawData.Length-position) + ", Struct length: "+rawsize);
throw new ArgumentException("Not enough data to fill struct. Array length from position: " + (rawData.Length-position) + ", Struct length: " + rawsize);
IntPtr buffer = Marshal.AllocHGlobal(rawsize);
Marshal.Copy(rawData, position, buffer, rawsize);
T retobj = (T)Marshal.PtrToStructure(buffer, typeof(T));
@@ -56,21 +56,19 @@ namespace Game
bufferEnumerable = buffer.GetEnumerator();
Console.Print("demo numstates: " + buffer.Count);
OnEndFrame(); // advances to first frame
}
public override void OnUpdate()
{
lastState = currentState;
}
public override void OnFixedUpdate()
{
}
private int asdf = 0;
public override void OnEndFrame()
{
// TODO: check if the current state frame matches the current frame number before advancing
/*asdf++;
if (asdf < 8)
return;*/
if (bufferEnumerable == null || !bufferEnumerable.MoveNext())
{
if (buffer.Any())
@@ -85,6 +83,7 @@ namespace Game
//var actorState = currentState.actor;
currentState.input = bufferEnumerable.Current;
//frame++;
//currentState.actor = actorState;
}
}

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using FlaxEngine;
using Console = Cabrito.Console;
namespace Game
{
@@ -14,10 +15,6 @@ namespace Game
public bool IsRecording { get { return demoFileStream != null; } }
public PlayerInputLocal()
{
}
public PlayerInputLocal(string demoPath)
{
demoFileStream = File.Open(demoPath, FileMode.Create, FileAccess.Write);
@@ -29,32 +26,55 @@ namespace Game
public override void OnUpdate()
{
lastState = currentState;
// Collect anything framerate independent here like camera movement
// All axis values here should be accumulated, and binary actions OR'ed
currentState.input.viewDeltaX += InputManager.GetAxisRaw("Mouse X");
currentState.input.viewDeltaY += InputManager.GetAxisRaw("Mouse Y");
// Record camera angles here?
currentState.input.viewDeltaX = InputManager.GetAxisRaw("Mouse X");
currentState.input.viewDeltaY = InputManager.GetAxisRaw("Mouse Y");
}
public override void OnFixedUpdate()
{
// Record intent here
currentState.input.moveForward = InputManager.GetAxis("Vertical");
currentState.input.moveRight = InputManager.GetAxis("Horizontal");
currentState.input.attacking = InputManager.GetAction("Attack");
currentState.input.jumping = InputManager.GetAction("Jump");
}
public override void OnFixedUpdate()
{
// Collect all input here
/*currentState.input.moveForward = InputManager.GetAxis("Vertical");
currentState.input.moveRight = InputManager.GetAxis("Horizontal");
currentState.input.attacking = InputManager.GetAction("Attack");
currentState.input.jumping = InputManager.GetAction("Jump");*/
}
public override void OnEndFrame()
{
if (IsRecording)
{
currentState.input.verificationPosition = currentState.actor.position;
currentState.input.verificationOrientation = currentState.actor.orientation;
currentState.input.verificationVelocity = currentState.actor.velocity;
currentState.input.frame = frame;
buffer.Add(currentState.input);
}
// Reset anything accumulatable here
currentState.input.viewDeltaX = 0;
currentState.input.viewDeltaY = 0;
frame++;
}
public override void RecordCurrentActorState(PlayerActorState actorState)
{
if (!IsRecording)
return;
if (actorState.position.Length <= 0.01)
Console.Print("wrong recorded position?");
currentState.actor = actorState;
}
public void FlushDemo()
{
if (!IsRecording)

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using FlaxEngine;
using System.Diagnostics;
using System.Threading.Tasks;
using FlaxEditor.CustomEditors.Editors;
using FlaxEngine.Assertions;
using Console = Cabrito.Console;
using Debug = FlaxEngine.Debug;
@@ -42,6 +43,10 @@ namespace Game
private float viewYaw;
private float viewRoll;
private float viewPitchLastFrame;
private float viewYawLastFrame;
private float viewRollLastFrame;
private InputEvent onExit = new InputEvent("Exit");
// FIXME, should be much smaller but needed to avoid issues with box collider edges against brush edges diagonally
@@ -56,8 +61,19 @@ namespace Game
{
base.OnAwake();
input = new PlayerInputLocal(@"C:\dev\GoakeFlax\testdemo.gdem"); // record
//input = new PlayerInputDemo(@"C:\dev\GoakeFlax\testdemo.gdem"); //playback
bool record = false;
//record = true;
if (record)
{
input = new PlayerInputLocal(@"C:\dev\GoakeFlax\testdemo.gdem"); // record
}
else
{
//input = new PlayerInputLocal();
input = new PlayerInputDemo(@"C:\dev\GoakeFlax\testdemo.gdem"); //playback
//input = new PlayerInputDemo(@"C:\dev\GoakeFlax\testdemo_desync.gdem"); //playback
}
onExit.Triggered += () =>
{
@@ -73,6 +89,8 @@ namespace Game
//rigidBody.CollisionEnter += OnCollisionEnter;
//rigidBody.TriggerEnter += OnTriggerEnter;
//rigidBody.TriggerExit += OnTriggerExit;
startupTime = Time.TimeSinceStartup;
}
public override void OnDisable()
@@ -120,13 +138,25 @@ namespace Game
viewPitch = initialEulerAngles.X;
viewYaw = initialEulerAngles.Y;
viewRoll = initialEulerAngles.Z;
viewPitchLastFrame = viewPitch;
viewYawLastFrame = viewYaw;
viewRollLastFrame = viewRoll;
}
private int lastInputFrame = 0;
private int currentInputFrame = 0;
private int currentInputFrame2 = 0;
private float startupTime = 0f;
public override void OnUpdate()
{
//input.OnUpdate();
if (input is PlayerInputDemo /*&& currentInputFrame2 >= currentInputFrame*/)
return;
input.OnUpdate();
if (input.frame > 0)
/*if (input.frame > 0)
{
PlayerActorState actorState = input.GetCurrentActorState();
Actor.Position = actorState.position;
@@ -134,11 +164,96 @@ namespace Game
viewYaw = actorState.viewYaw;
viewPitch = actorState.viewPitch;
viewRoll = actorState.viewRoll;
}
}*/
PlayerInputState inputState = input.GetCurrentInputState();
// Update camera view
viewYaw = viewYawLastFrame;
viewPitch = viewPitchLastFrame;
viewRoll = viewRollLastFrame;
ApplyInputToCamera(inputState);
/*input.RecordCurrentActorState(new PlayerActorState()
{
position = Actor.Position,
velocity = currentVelocity,
orientation = rootActor.Orientation,
viewYaw = viewYaw,
viewPitch = viewPitch,
viewRoll = viewRoll
});*/
currentInputFrame2++;
}
private Vector3 fixedPosition = Vector3.Zero;
private bool newframe = false;
public override void OnFixedUpdate()
{
if (input is PlayerInputDemo)
input.OnUpdate();
input.OnFixedUpdate();
PlayerInputState inputState = input.GetCurrentInputState();
if (input is PlayerInputDemo)
ApplyInputToCamera(inputState);
SimulatePlayerMovement(inputState);
if (input is PlayerInputDemo)
{
// verify
float positionDelta = (Actor.Position - inputState.verificationPosition).Length;
if (positionDelta > 0.00001)
Console.Print("pos delta: " + positionDelta + " " + (Actor.Position - inputState.verificationPosition));
float velocityDelta = (currentVelocity - inputState.verificationVelocity).Length;
if (velocityDelta > 0.00001)
Console.Print("pos vel: " + velocityDelta);
float orientationDelta = (rootActor.Orientation - inputState.verificationOrientation).Length;
if (orientationDelta > 0.00001)
Console.Print("pos orient: " + rootActor.Orientation);
//if (currentInputFrame == 0)
/*{
//Console.Print("repos: " + inputState.verificationPosition);
Actor.Position = inputState.verificationPosition;
currentVelocity = inputState.verificationVelocity;
rootActor.Orientation = inputState.verificationOrientation;
}*/
}
input.RecordCurrentActorState(new PlayerActorState()
{
position = Actor.Position,
velocity = currentVelocity,
orientation = rootActor.Orientation,
viewYaw = viewYaw,
viewPitch = viewPitch,
viewRoll = viewRoll
});
input.OnEndFrame();
lastInputFrame = currentInputFrame;
currentInputFrame++;
viewPitchLastFrame = viewPitch;
viewYawLastFrame = viewYaw;
viewRollLastFrame = viewRoll;
fixedPosition = Actor.Position;
newframe = true;
}
private void ApplyInputToCamera(PlayerInputState inputState)
{
// Update camera viewf
float xAxis = inputState.viewDeltaX;
float yAxis = inputState.viewDeltaY;
if (xAxis != 0.0f || yAxis != 0.0f)
@@ -148,56 +263,24 @@ namespace Game
viewPitch = Mathf.Clamp(viewPitch + yAxis, -90.0f, 90.0f);
viewYaw += xAxis;
// root orientation must be set first
rootActor.Orientation = Quaternion.Euler(0, viewYaw, 0);
camera.Orientation = Quaternion.Euler(viewPitch, viewYaw, viewRoll);
}
input.RecordCurrentActorState(new PlayerActorState()
{
position = Actor.Position,
velocity = currentVelocity,
viewYaw = viewYaw,
viewPitch = viewPitch,
viewRoll = viewRoll
});
}
public override void OnFixedUpdate()
{
input.OnFixedUpdate();
PlayerInputState inputState = input.GetCurrentInputState();
SimulatePlayerMovement(inputState);
input.RecordCurrentActorState(new PlayerActorState()
{
position = Actor.Position,
velocity = currentVelocity,
viewYaw = viewYaw,
viewPitch = viewPitch,
viewRoll = viewRoll
});
input.OnEndFrame();
}
private bool SweepPlayerCollider(Vector3 start, Vector3 end, out RayCastHit[] hits)
private static bool SweepPlayerCollider(Actor actor, Vector3 start, Vector3 end, out RayCastHit[] hits)
{
Vector3 delta = end - start;
float maxDistance = delta.Length;
Vector3 direction = delta.Normalized;
bool collided = false;
var capsuleCollider = Actor.GetChild<CapsuleCollider>();
var boxCollider = Actor.GetChild<BoxCollider>();
var meshCollider = Actor.GetChild<MeshCollider>();
PhysicsColliderActor colliderActor = null;
var capsuleCollider = actor.GetChild<CapsuleCollider>();
var boxCollider = actor.GetChild<BoxCollider>();
var meshCollider = actor.GetChild<MeshCollider>();
if (capsuleCollider && capsuleCollider.IsActive)
{
colliderActor = capsuleCollider;
collided = Physics.CapsuleCastAll(start,
capsuleCollider.Radius, capsuleCollider.Height,
direction, out hits, capsuleCollider.Orientation, maxDistance,
@@ -206,7 +289,6 @@ namespace Game
}
else if (meshCollider && meshCollider.IsActive)
{
colliderActor = meshCollider;
collided = Physics.ConvexCastAll(start,
meshCollider.CollisionData, meshCollider.Scale,
direction, out hits, meshCollider.Orientation, maxDistance,
@@ -215,7 +297,6 @@ namespace Game
}
else if (boxCollider && boxCollider.IsActive)
{
colliderActor = boxCollider;
collided = Physics.BoxCastAll(start,
boxCollider.OrientedBox.Extents,
direction, out hits, boxCollider.Orientation, maxDistance,
@@ -291,10 +372,11 @@ namespace Game
/// <summary>
/// Sweeps the player rigidbody in world and returns geometry which was hit during the trace.
/// </summary>
/// <param name="actor">Player actor</param>
/// <param name="start">Start position</param>
/// <param name="end">End position</param>
/// <returns></returns>
private TraceInfo TracePlayer(Vector3 start, Vector3 end)
private static TraceInfo TracePlayer(Actor actor, Vector3 start, Vector3 end)
{
TraceInfo traceInfo = new TraceInfo();
@@ -302,7 +384,7 @@ namespace Game
float maxDistance = delta.Length;
Vector3 direction = delta.Normalized;
bool collided = SweepPlayerCollider(start, end, out traceInfo.hitInfos);
bool collided = SweepPlayerCollider(actor, start, end, out traceInfo.hitInfos);
if (collided)
{
List<RayCastHit> hitInfosFiltered = new List<RayCastHit>();
@@ -312,7 +394,7 @@ namespace Game
{
//if (hitInfo.Collider == colliderActor)
// continue;
if (hitInfo.Collider.Parent == Actor)
if (hitInfo.Collider.Parent == actor)
continue;
hitInfosFiltered.Add(hitInfo);
@@ -411,7 +493,7 @@ namespace Game
}
#endif
private SlideMoveHit StepSlideMove(ref Vector3 position, ref Vector3 velocity, bool onGround)
private static SlideMoveHit StepSlideMove(Actor actor, ref Vector3 position, ref Vector3 velocity, bool onGround)
{
if (velocity.IsZero)
return SlideMoveHit.Nothing;
@@ -419,7 +501,7 @@ namespace Game
Vector3 originalPosition = position;
Vector3 originalVelocity = velocity;
SlideMoveHit slideMoveHit = SlideMove(ref position, ref velocity);
SlideMoveHit slideMoveHit = SlideMove(actor, ref position, ref velocity);
if (slideMoveHit == SlideMoveHit.Nothing)
{
// TODO: step down here
@@ -438,16 +520,16 @@ namespace Game
// step up
Vector3 stepUp = position + stepDelta;
TraceInfo traceUp = TracePlayer(position, stepUp);
TraceInfo traceUp = TracePlayer(actor, position, stepUp);
if (traceUp.fraction > 0f)
position = traceUp.endPosition;
// try moving from step up position
SlideMoveHit slideMoveStepHit = SlideMove(ref position, ref velocity);
SlideMoveHit slideMoveStepHit = SlideMove(actor, ref position, ref velocity);
// step down
Vector3 stepDown = position - stepDelta;
TraceInfo traceDown = TracePlayer(position, stepDown);
TraceInfo traceDown = TracePlayer(actor, position, stepDown);
if (traceDown.fraction < 1f && -Vector3.Dot(Physics.Gravity.Normalized, traceDown.hitNormal) < slopeNormal)
{
// can't step down, slide move like normally
@@ -508,7 +590,7 @@ namespace Game
Other = 4,
}
private SlideMoveHit SlideMove(ref Vector3 position, ref Vector3 velocity)
private static SlideMoveHit SlideMove(Actor actor, ref Vector3 position, ref Vector3 velocity)
{
if (velocity.IsZero)
return SlideMoveHit.Nothing;
@@ -526,7 +608,7 @@ namespace Game
Vector3 startPos = position;
Vector3 endPos = position + (velocity * timeleft);
TraceInfo trace = TracePlayer(startPos, endPos);
TraceInfo trace = TracePlayer(actor, startPos, endPos);
// TODO: handle portals here
float fraction = trace.fraction;
@@ -725,7 +807,7 @@ namespace Game
Vector3 groundDelta = Physics.Gravity.Normalized;//Physics.Gravity.Normalized * (collisionMargin * 2);
//if (velocity.Y < 0f)
// groundDelta = Physics.Gravity.Normalized * velocity.Y * Time.DeltaTime;
TraceInfo traceGround = TracePlayer(position, position + groundDelta);
TraceInfo traceGround = TracePlayer(Actor, position, position + groundDelta);
if (!traceGround.startSolid && traceGround.fraction < 1f &&
-Vector3.Dot(Physics.Gravity.Normalized, traceGround.hitNormal) < slopeNormal)
@@ -742,8 +824,10 @@ namespace Game
Vector3 point = (position + groundDelta) +
(1f - traceGround.fraction) * bounce;
Console.Print("backoff: " + backoff);
// retrace
traceGround = TracePlayer(position, position + point);
traceGround = TracePlayer(Actor, position, position + point);
}
if (!traceGround.startSolid && (traceGround.fraction >= 1f ||
@@ -751,9 +835,12 @@ namespace Game
{
// falling or sliding down a slope
onGround = false;
}
else
{
//if (onGround != !traceGround.startSolid)
// Console.Print("slidefrac: " + traceGround.fraction);
onGround = !traceGround.startSolid;
//Console.Print("issolid? :" + traceGround.startSolid);
}
@@ -770,6 +857,7 @@ namespace Game
// jump
if (onGround && jumpAction && !jumped)
{
// reset velocity from gravity
if (-Vector3.Dot(Physics.Gravity.Normalized, velocity) < 0 &&
Vector3.Dot(velocity, traceGround.hitNormal) < -0.1)
@@ -782,7 +870,7 @@ namespace Game
jumped = true;
lastJumped = Time.GameTime;
var jumpLandSound = JumpLandSound;
/*var jumpLandSound = JumpLandSound;
for (int i = 0; i < 10; i++)
{
var r = soundRandom.Next(3);
@@ -808,7 +896,7 @@ namespace Game
audioSource.Play();
Destroy(audioSource, jumpLandSound.Length);
lastJumpLandSound = jumpLandSound;
}
}*/
}
if (onGround)
@@ -874,15 +962,16 @@ namespace Game
// apply gravity
velocity += Physics.Gravity * Time.DeltaTime;
//Console.Print(Time.DeltaTime.ToString());
}
safePosition = rigidBody.Position;
//safePosition = rigidBody.Position;
currentVelocity = velocity;
if (!rigidBody.IsKinematic)
/*if (!rigidBody.IsKinematic)
rigidBody.LinearVelocity = velocity;
else
else*/
{
StepSlideMove(ref position, ref velocity, onGround);
StepSlideMove(Actor, ref position, ref velocity, onGround);
rigidBody.Position = position;
currentVelocity = velocity;
@@ -890,7 +979,7 @@ namespace Game
}
}
void ApplyAcceleration(ref Vector3 velocity, Vector3 wishDir, float wishspeed, float maxWishspeed,
private static void ApplyAcceleration(ref Vector3 velocity, Vector3 wishDir, float wishspeed, float maxWishspeed,
float acceleration)
{
float wishspeedOrig = wishspeed;

View File

@@ -27,6 +27,7 @@ namespace Game
public Dictionary<string, MaterialBase> brushMaterials;
}
[ExecuteInEditMode]
public class Q3MapImporter : Script
{
//private string mapPath = @"C:\dev\GoakeFlax\Assets\Maps\cube_q1.map";
@@ -188,6 +189,25 @@ namespace Game
public override void OnStart()
{
LoadMap(false);
}
private void LoadMap(bool forceLoad)
{
Actor worldSpawnActor = Actor.GetChild("WorldSpawn");
if (worldSpawnActor != null)
{
if (forceLoad)
{
worldSpawnActor.DestroyChildren();
}
else
{
//Console.Print("Map already loaded in the scene");
return;
}
}
{
var workDir = Directory.GetCurrentDirectory();
var matBasePath = Path.Combine(workDir, "Content", "Materials");
@@ -218,8 +238,11 @@ namespace Game
}
}
var worldSpawnActor = Actor.AddChild<Actor>();
worldSpawnActor.Name = "WorldSpawn";
if (worldSpawnActor == null)
{
worldSpawnActor = Actor.AddChild<Actor>();
worldSpawnActor.Name = "WorldSpawn";
}
List<BrushGeometry> brushGeometries = new List<BrushGeometry>(root.entities[0].brushes.Count);
@@ -265,7 +288,8 @@ namespace Game
materials.Add(textureName, brushMaterial);
else
{
Console.Print("Material '" + textureName + "' not found for brush");
// TODO: engine doesn't seem to always load the asset even though it exists, bug? seems to happen at low framerate
Console.Print("Material '" + textureName + "' not found for brush, assetPath: " + assetPath);
materials.Add(textureName, missingMaterial);
brushMaterial = missingMaterial;
}