diff --git a/Prowl.Runtime/AssetImporting/ModelImporter.cs b/Prowl.Runtime/AssetImporting/ModelImporter.cs index 1985f026..cbf61eab 100644 --- a/Prowl.Runtime/AssetImporting/ModelImporter.cs +++ b/Prowl.Runtime/AssetImporting/ModelImporter.cs @@ -124,6 +124,18 @@ private Model BuildModel(Assimp.Scene scene, string assetPath, DirectoryInfo? pa // Build the model structure model.RootNode = BuildModelNode(scene.RootNode, scale); + var rootTransform = scene.RootNode.Transform; + Float4x4 rootMatrix = new Float4x4( + rootTransform.A1, rootTransform.A2, rootTransform.A3, rootTransform.A4, + rootTransform.B1, rootTransform.B2, rootTransform.B3, rootTransform.B4, + rootTransform.C1, rootTransform.C2, rootTransform.C3, rootTransform.C4, + rootTransform.D1, rootTransform.D2, rootTransform.D3, rootTransform.D4 + ); + + rootMatrix.Translation *= (float)scale; + + model.GlobalInverseTransform = rootMatrix.Invert(); + // Load materials and meshes into the model if (scene.HasMaterials) LoadMaterials(scene, parentDir, model.Materials); @@ -132,27 +144,9 @@ private Model BuildModel(Assimp.Scene scene, string assetPath, DirectoryInfo? pa LoadMeshes(assetPath, settings, scene, scale, model.Materials, model.Meshes); // Animations - List anims = []; if (scene.HasAnimations) LoadAnimations(scene, scale, model.Animations); - //if (CullEmpty) - //{ - // // Remove Empty GameObjects - // List<(MeshRenderer, Node)> GOsToRemove = []; - // foreach (var go in GOs) - // { - // if (go.Item1.GetEntitiesInChildren().Count(x => x.Mesh.IsAvailable) == 0) - // GOsToRemove.Add(go); - // } - // foreach (var go in GOsToRemove) - // { - // if (!go.Item1.IsDestroyed) - // go.Item1.DestroyImmediate(); - // GOs.Remove(go); - // } - //} - return model; } @@ -177,7 +171,6 @@ private void LoadMaterials(Assimp.Scene? scene, DirectoryInfo? parentDir, List Parent -> Parent -> ... -> Parent -> Bone - Assimp.Node target = boneNode; - string path = target.Name; - //while (target.Parent != null) - //{ - // target = target.Parent; - // path = target.Name + "/" + path; - // if (target.Name == scene.RootNode.Name) // TODO: Can we just do reference comparison here instead of string comparison? - // break; - //} - + if (channel.HasPositionKeys) { var xCurve = new AnimationCurve(); var yCurve = new AnimationCurve(); var zCurve = new AnimationCurve(); + + xCurve.Keys.Clear(); + yCurve.Keys.Clear(); + zCurve.Keys.Clear(); + foreach (var posKey in channel.PositionKeys) { double time = (posKey.Time / anim.DurationInTicks) * animation.Duration; - xCurve.Keys.Add(new(time, posKey.Value.X * scale)); - yCurve.Keys.Add(new(time, posKey.Value.Y * scale)); - zCurve.Keys.Add(new(time, posKey.Value.Z * scale)); + xCurve.Keys.Add(new KeyFrame(time, posKey.Value.X * scale)); + yCurve.Keys.Add(new KeyFrame(time, posKey.Value.Y * scale)); + zCurve.Keys.Add(new KeyFrame(time, posKey.Value.Z * scale)); } animBone.PosX = xCurve; animBone.PosY = yCurve; animBone.PosZ = zCurve; } - + if (channel.HasRotationKeys) { var xCurve = new AnimationCurve(); var yCurve = new AnimationCurve(); var zCurve = new AnimationCurve(); var wCurve = new AnimationCurve(); + + xCurve.Keys.Clear(); + yCurve.Keys.Clear(); + zCurve.Keys.Clear(); + wCurve.Keys.Clear(); + foreach (var rotKey in channel.RotationKeys) { double time = (rotKey.Time / anim.DurationInTicks) * animation.Duration; - xCurve.Keys.Add(new(time, rotKey.Value.X)); - yCurve.Keys.Add(new(time, rotKey.Value.Y)); - zCurve.Keys.Add(new(time, rotKey.Value.Z)); - wCurve.Keys.Add(new(time, rotKey.Value.W)); + xCurve.Keys.Add(new KeyFrame(time, rotKey.Value.X)); + yCurve.Keys.Add(new KeyFrame(time, rotKey.Value.Y)); + zCurve.Keys.Add(new KeyFrame(time, rotKey.Value.Z)); + wCurve.Keys.Add(new KeyFrame(time, rotKey.Value.W)); } animBone.RotX = xCurve; animBone.RotY = yCurve; animBone.RotZ = zCurve; animBone.RotW = wCurve; } - + if (channel.HasScalingKeys) { var xCurve = new AnimationCurve(); var yCurve = new AnimationCurve(); var zCurve = new AnimationCurve(); + + xCurve.Keys.Clear(); + yCurve.Keys.Clear(); + zCurve.Keys.Clear(); + foreach (var scaleKey in channel.ScalingKeys) { double time = (scaleKey.Time / anim.DurationInTicks) * animation.Duration; - xCurve.Keys.Add(new(time, scaleKey.Value.X)); - yCurve.Keys.Add(new(time, scaleKey.Value.Y)); - zCurve.Keys.Add(new(time, scaleKey.Value.Z)); + xCurve.Keys.Add(new KeyFrame(time, scaleKey.Value.X)); + yCurve.Keys.Add(new KeyFrame(time, scaleKey.Value.Y)); + zCurve.Keys.Add(new KeyFrame(time, scaleKey.Value.Z)); } animBone.ScaleX = xCurve; animBone.ScaleY = yCurve; animBone.ScaleZ = zCurve; } - + animation.AddBone(animBone); } - + animation.EnsureQuaternionContinuity(); animations.Add(animation); } diff --git a/Prowl.Runtime/Assets/Defaults/Line.shader b/Prowl.Runtime/Assets/Defaults/Line.shader new file mode 100644 index 00000000..3c4f0655 --- /dev/null +++ b/Prowl.Runtime/Assets/Defaults/Line.shader @@ -0,0 +1,84 @@ +Shader "Default/Line" + +Properties +{ + _MainTex ("Texture", Texture2D) = "white" + _StartColor ("Start Color", Color) = (1.0, 1.0, 1.0, 1.0) + _EndColor ("End Color", Color) = (1.0, 1.0, 1.0, 1.0) +} + +Pass "Line" +{ + Tags { "RenderOrder" = "Transparent" } + Cull Off + Blend Alpha + + GLSLPROGRAM + Vertex + { + #include "Fragment" + #include "VertexAttributes" + + out vec2 texCoord0; + out vec3 worldPos; + out vec4 currentPos; + out vec4 previousPos; + out float fogCoord; + out vec4 vColor; + + void main() + { + gl_Position = PROWL_MATRIX_MVP * vec4(vertexPosition, 1.0); + fogCoord = gl_Position.z; + currentPos = gl_Position; + texCoord0 = vertexTexCoord0; + + vec4 prevWorldPos = PROWL_MATRIX_M_PREVIOUS * vec4(vertexPosition, 1.0); + previousPos = PROWL_MATRIX_VP_PREVIOUS * prevWorldPos; + + worldPos = (PROWL_MATRIX_M * vec4(vertexPosition, 1.0)).xyz; + vColor = vertexColor; + } + } + + Fragment + { + #include "Fragment" + + layout (location = 0) out vec4 gAlbedo; + layout (location = 1) out vec4 gMotionVector; + layout (location = 2) out vec4 gNormal; + layout (location = 3) out vec4 gSurface; + + in vec2 texCoord0; + in vec3 worldPos; + in vec4 currentPos; + in vec4 previousPos; + in float fogCoord; + in vec4 vColor; + + uniform sampler2D _MainTex; + + void main() + { + vec2 curNDC = (currentPos.xy / currentPos.w) - _CameraJitter; + vec2 prevNDC = (previousPos.xy / previousPos.w) - _CameraPreviousJitter; + gMotionVector = vec4((curNDC - prevNDC) * 0.5, 0.0, 1.0); + + vec4 albedo = texture(_MainTex, texCoord0) * vColor; + + // Lines don't have meaningful normals in billboarded mode + gNormal = vec4(0.0, 0.0, 1.0, 1.0); + + // Unlit surface properties + gSurface = vec4(1.0, 0.0, 0.0, 1.0); + + vec3 baseColor = albedo.rgb; + baseColor.rgb = GammaToLinearSpace(baseColor.rgb); + + gAlbedo = vec4(baseColor, albedo.a); + gAlbedo.rgb = ApplyFog(fogCoord, gAlbedo.rgb); + } + } + ENDGLSL +} diff --git a/Prowl.Runtime/Assets/Defaults/Unlit.shader b/Prowl.Runtime/Assets/Defaults/Unlit.shader new file mode 100644 index 00000000..440b117c --- /dev/null +++ b/Prowl.Runtime/Assets/Defaults/Unlit.shader @@ -0,0 +1,131 @@ +Shader "Default/Unlit" + +Properties +{ + _MainTex ("Texture", Texture2D) = "white" + _MainColor ("Tint", Color) = (1.0, 1.0, 1.0, 1.0) +} + +Pass "Unlit" +{ + Tags { "RenderOrder" = "Opaque" } + Cull Back + + GLSLPROGRAM + Vertex + { + #include "Fragment" + #include "VertexAttributes" + + out vec2 texCoord0; + out vec3 worldPos; + out vec4 currentPos; + out vec4 previousPos; + out float fogCoord; + out vec4 vColor; + out vec3 vNormal; + + void main() + { +#ifdef SKINNED + vec4 skinnedPos = GetSkinnedPosition(vertexPosition); + vec3 skinnedNormal = GetSkinnedNormal(vertexNormal); + + gl_Position = PROWL_MATRIX_MVP * skinnedPos; + fogCoord = gl_Position.z; + currentPos = gl_Position; + texCoord0 = vertexTexCoord0; + + vec4 prevWorldPos = PROWL_MATRIX_M_PREVIOUS * skinnedPos; + previousPos = PROWL_MATRIX_VP_PREVIOUS * prevWorldPos; + + worldPos = (PROWL_MATRIX_M * skinnedPos).xyz; + vColor = vertexColor; + vNormal = normalize(mat3(PROWL_MATRIX_M) * skinnedNormal); +#else + gl_Position = PROWL_MATRIX_MVP * vec4(vertexPosition, 1.0); + fogCoord = gl_Position.z; + currentPos = gl_Position; + texCoord0 = vertexTexCoord0; + + vec4 prevWorldPos = PROWL_MATRIX_M_PREVIOUS * vec4(vertexPosition, 1.0); + previousPos = PROWL_MATRIX_VP_PREVIOUS * prevWorldPos; + + worldPos = (PROWL_MATRIX_M * vec4(vertexPosition, 1.0)).xyz; + vColor = vertexColor; + vNormal = normalize(mat3(PROWL_MATRIX_M) * vertexNormal); +#endif + } + } + + Fragment + { + #include "Fragment" + + layout (location = 0) out vec4 gAlbedo; + layout (location = 1) out vec4 gMotionVector; + layout (location = 2) out vec4 gNormal; + layout (location = 3) out vec4 gSurface; + + in vec2 texCoord0; + in vec3 worldPos; + in vec4 currentPos; + in vec4 previousPos; + in float fogCoord; + in vec4 vColor; + in vec3 vNormal; + + uniform sampler2D _MainTex; + + void main() + { + vec2 curNDC = (currentPos.xy / currentPos.w) - _CameraJitter; + vec2 prevNDC = (previousPos.xy / previousPos.w) - _CameraPreviousJitter; + gMotionVector = vec4((curNDC - prevNDC) * 0.5, 0.0, 1.0); + + vec4 albedo = texture(_MainTex, texCoord0) * vColor; + + vec3 viewNormal = normalize(mat3(PROWL_MATRIX_V) * vNormal); + gNormal = vec4(viewNormal, 1.0); + + gSurface = vec4(1.0, 0.0, 0.0, 1.0); + + vec3 baseColor = albedo.rgb; + baseColor.rgb = GammaToLinearSpace(baseColor.rgb); + + gAlbedo = vec4(baseColor, albedo.a); + gAlbedo.rgb = ApplyFog(fogCoord, gAlbedo.rgb); + } + } + ENDGLSL +} + +Pass "UnlitMotionVector" +{ + Tags { "RenderOrder" = "DepthOnly" } + Cull Back + + GLSLPROGRAM + Vertex + { + #include "Fragment" + #include "VertexAttributes" + + void main() + { +#ifdef SKINNED + vec4 skinnedPos = GetSkinnedPosition(vertexPosition); + gl_Position = PROWL_MATRIX_MVP * skinnedPos; +#else + gl_Position = PROWL_MATRIX_MVP * vec4(vertexPosition, 1.0); +#endif + } + } + + Fragment + { + #include "Fragment" + void main() {} + } + ENDGLSL +} diff --git a/Prowl.Runtime/Components/LineRenderer.cs b/Prowl.Runtime/Components/LineRenderer.cs new file mode 100644 index 00000000..5b74982f --- /dev/null +++ b/Prowl.Runtime/Components/LineRenderer.cs @@ -0,0 +1,370 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using Prowl.Runtime.Rendering; +using Prowl.Runtime.Resources; +using Prowl.Runtime.GraphicsBackend.Primitives; +using Prowl.Vector; +using Prowl.Vector.Geometry; + +namespace Prowl.Runtime; + +public class LineRenderer : MonoBehaviour, IRenderable +{ + public Material Material; + public float StartWidth = 0.1f; + public float EndWidth = 0.1f; + public List Points = new(); + public bool Loop = false; + public Color StartColor = Color.white; + public Color EndColor = Color.white; + public TextureWrapMode TextureMode = TextureWrapMode.Stretch; + public float TextureTiling = 1.0f; // Controls UV tiling for Tile mode + public bool RecalculateNormals = false; + + private Mesh? _cachedMesh; + private ViewerData _lastViewer; + private bool _meshGenerated; + private bool _isDirty = true; + private AABBD _bounds; + + // Cached state for change detection + private List _lastPoints; + private float _lastStartWidth; + private float _lastEndWidth; + private bool _lastLoop; + private Color _lastStartColor; + private Color _lastEndColor; + private TextureWrapMode _lastTextureMode; + private float _lastTextureTiling; + + public override void Awake() + { + _lastPoints = new List(); + _lastStartColor = StartColor; + _lastEndColor = EndColor; + _lastStartWidth = StartWidth; + _lastEndWidth = EndWidth; + _lastTextureMode = TextureMode; + _lastTextureTiling = TextureTiling; + } + + public override void Update() + { + if (Material != null && Points != null && Points.Count >= 2) + { + // Check if we need to regenerate + bool needsUpdate = _isDirty || + _cachedMesh == null || + StartWidth != _lastStartWidth || + EndWidth != _lastEndWidth || + Loop != _lastLoop || + !StartColor.Equals(_lastStartColor) || + !EndColor.Equals(_lastEndColor) || + TextureMode != _lastTextureMode || + Math.Abs(TextureTiling - _lastTextureTiling) > 0.001f || + !PointsEqual(_lastPoints, Points); + + if (needsUpdate) + { + // Update cached state + _lastPoints = new List(Points); + _lastStartWidth = StartWidth; + _lastEndWidth = EndWidth; + _lastLoop = Loop; + _lastStartColor = StartColor; + _lastEndColor = EndColor; + _lastTextureMode = TextureMode; + _lastTextureTiling = TextureTiling; + _isDirty = true; // Flag for next render + + CalculateBounds(); + } + + // Always push the renderable (IRenderable interface handles actual rendering) + GameObject.Scene.PushRenderable(this); + } + } + + private bool PointsEqual(List a, List b) + { + if (a.Count != b.Count) return false; + + for (int i = 0; i < a.Count; i++) + { + if (!a[i].Equals(b[i])) return false; + } + + return true; + } + + private void CalculateBounds() + { + if (Points == null || Points.Count == 0) + { + _bounds = new AABBD(); + return; + } + + // Transform points to world space for bounds calculation + Double3 min = Transform.TransformPoint(Points[0]); + Double3 max = min; + + foreach (var point in Points) + { + Double3 worldPoint = Transform.TransformPoint(point); + min = Maths.Min(min, worldPoint); + max = Maths.Max(max, worldPoint); + } + + // Expand bounds by maximum line width + double maxWidth = Math.Max(StartWidth, EndWidth); + Double3 expansion = new Double3(maxWidth, maxWidth, maxWidth); + min -= expansion; + max += expansion; + + _bounds = new AABBD(min, max); + } + + public void MarkDirty() + { + _isDirty = true; + } + + // Helper methods for point manipulation + public void SetPosition(int index, Double3 position) + { + if (index >= 0 && index < Points.Count) + { + Points[index] = position; + _isDirty = true; + } + } + + public Double3 GetPosition(int index) + { + if (index >= 0 && index < Points.Count) + return Points[index]; + return Double3.Zero; + } + + public void SetPositions(List positions) + { + Points = new List(positions); + _isDirty = true; + } + + public void SetPositions(Double3[] positions) + { + Points = new List(positions); + _isDirty = true; + } + + public override void OnDisable() + { + // Clean up the mesh when disabled + _cachedMesh?.OnDispose(); + _cachedMesh = null; + } + + #region IRenderable Implementation + + public Material GetMaterial() => Material; + public int GetLayer() => GameObject.layerIndex; + + public void GetRenderingData(ViewerData viewer, out PropertyState properties, out Mesh drawData, out Double4x4 model) + { + // Create mesh only once + if (_cachedMesh == null) + { + _cachedMesh = new Mesh(); + } + + // Always regenerate for smooth billboarding (billboard lines always face camera) + UpdateBillboardedMesh(_cachedMesh, viewer); + + _lastViewer = viewer; + _meshGenerated = true; + _isDirty = false; + + // Setup properties + properties = new PropertyState(); + properties.SetInt("_ObjectID", InstanceID); + properties.SetColor("_StartColor", StartColor); + properties.SetColor("_EndColor", EndColor); + + drawData = _cachedMesh!; + model = Double4x4.Identity; // Vertices are already in world space + } + + public void GetCullingData(out bool isRenderable, out AABBD bounds) + { + isRenderable = Points != null && Points.Count >= 2 && Material != null; + bounds = _bounds; + } + + #endregion + + private void UpdateBillboardedMesh(Mesh mesh, ViewerData viewer) + { + if (Points.Count < 2) + return; + + // Transform points to world space + List worldPoints = new List(Points.Count); + foreach (var point in Points) + { + worldPoints.Add(Transform.TransformPoint(point)); + } + + // Add loop point if needed + if (Loop && worldPoints.Count > 2) + { + worldPoints.Add(worldPoints[0]); + } + + int segmentCount = worldPoints.Count - 1; + int vertexCount = worldPoints.Count * 2; + int triangleCount = segmentCount * 2; + + Float3[] vertices = new Float3[vertexCount]; + Float2[] uvs = new Float2[vertexCount]; + Color[] colors = new Color[vertexCount]; + uint[] indices = new uint[triangleCount * 3]; + + // Calculate total line length for distance-based UV mapping + float totalLength = 0f; + float[] segmentLengths = new float[segmentCount]; + + if (TextureMode == TextureWrapMode.RepeatPerSegment || TextureMode == TextureWrapMode.Tile) + { + for (int i = 0; i < segmentCount; i++) + { + float length = (float)Maths.Distance(worldPoints[i], worldPoints[i + 1]); + segmentLengths[i] = length; + totalLength += length; + } + } + + // Generate vertices + float accumulatedLength = 0f; + + for (int i = 0; i < worldPoints.Count; i++) + { + Double3 point = worldPoints[i]; + + // Calculate line direction + Double3 lineDir; + if (i == 0) + { + lineDir = Maths.Normalize(worldPoints[i + 1] - point); + } + else if (i == worldPoints.Count - 1) + { + lineDir = Maths.Normalize(point - worldPoints[i - 1]); + } + else + { + lineDir = Maths.Normalize((worldPoints[i + 1] - worldPoints[i - 1]) * 0.5); + } + + // Calculate perpendicular vector (billboard direction towards camera) + Double3 toCamera = Maths.Normalize(viewer.Position - point); + Double3 right = Maths.Normalize(Maths.Cross(toCamera, lineDir)); + + // If cross product is near zero (line points at camera), use camera up vector + if (Maths.LengthSquared(right) < 0.001) + { + right = Maths.Normalize(Maths.Cross(viewer.Up, lineDir)); + } + + // Interpolate width along the line + float t = i / (float)(worldPoints.Count - 1); + float width = Maths.Lerp(StartWidth, EndWidth, t); + float halfWidth = width * 0.5f; + + // Create offset vertices + Double3 offset = right * halfWidth; + + vertices[i * 2] = (Float3)(point - offset); + vertices[i * 2 + 1] = (Float3)(point + offset); + + // Calculate U coordinate based on texture mode + float u = CalculateUCoordinate(i, worldPoints.Count, accumulatedLength, totalLength); + + uvs[i * 2] = new Float2(u, 0); + uvs[i * 2 + 1] = new Float2(u, 1); + + // Update accumulated length for next iteration + if (i < segmentCount) + { + accumulatedLength += segmentLengths[i]; + } + + // Colors (interpolate from start to end) + Color color = Maths.Lerp(StartColor, EndColor, t); + colors[i * 2] = color; + colors[i * 2 + 1] = color; + } + + // Generate triangles + int triIndex = 0; + for (int i = 0; i < segmentCount; i++) + { + uint baseVertex = (uint)(i * 2); + + // First triangle + indices[triIndex++] = baseVertex; + indices[triIndex++] = baseVertex + 2; + indices[triIndex++] = baseVertex + 1; + + // Second triangle + indices[triIndex++] = baseVertex + 1; + indices[triIndex++] = baseVertex + 2; + indices[triIndex++] = baseVertex + 3; + } + + // Update the existing mesh instead of creating a new one + mesh.Vertices = vertices; + mesh.UV = uvs; + mesh.Colors = colors; + mesh.Indices = indices; + + if (RecalculateNormals) + { + mesh.RecalculateNormals(); + } + + mesh.RecalculateBounds(); + } + + private float CalculateUCoordinate(int index, int totalPoints, float accumulatedLength, float totalLength) + { + float t = index / (float)(totalPoints - 1); + + return TextureMode switch + { + TextureWrapMode.Stretch => t, // 0 to 1 stretched across entire line + + TextureWrapMode.Tile => totalLength > 0 ? (accumulatedLength / totalLength) * TextureTiling : t, // Repeat based on world distance + + TextureWrapMode.RepeatPerSegment => index, // Each segment gets 0 to 1 + + _ => t + }; + } +} + +public enum TextureWrapMode +{ + /// Stretch texture from start to end (0 to 1) + Stretch, + + /// Tile texture based on world-space distance + Tile, + + /// Repeat texture for each point (creates 0,1,2,3... pattern) + RepeatPerSegment +} diff --git a/Prowl.Runtime/Components/ModelRenderer.cs b/Prowl.Runtime/Components/ModelRenderer.cs index 2ad6c857..86d2992b 100644 --- a/Prowl.Runtime/Components/ModelRenderer.cs +++ b/Prowl.Runtime/Components/ModelRenderer.cs @@ -19,7 +19,7 @@ public class ModelRenderer : MonoBehaviour public AnimationClip CurrentAnimation; public bool PlayAutomatically = true; public bool Loop = true; - public double AnimationSpeed = 0.1; + public double AnimationSpeed = 10.0; private double _animationTime = 0.0; private bool _isPlaying = false; diff --git a/Prowl.Runtime/Resources/AnimationClip.cs b/Prowl.Runtime/Resources/AnimationClip.cs index e8e35a76..fa393feb 100644 --- a/Prowl.Runtime/Resources/AnimationClip.cs +++ b/Prowl.Runtime/Resources/AnimationClip.cs @@ -1,6 +1,7 @@ // This file is part of the Prowl Game Engine // Licensed under the MIT License. See the LICENSE file in the project root for details. +using System; using System.Collections.Generic; using System.Linq; @@ -164,11 +165,100 @@ public Double3 EvaluatePositionAt(double time) => new Double3(PosX.Evaluate(time), PosY.Evaluate(time), PosZ.Evaluate(time)); public Quaternion EvaluateRotationAt(double time) - => new Quaternion((float)RotX.Evaluate(time), (float)RotY.Evaluate(time), (float)RotZ.Evaluate(time), (float)RotW.Evaluate(time)); + { + // Use SLERP for smooth quaternion interpolation + if (RotX.Keys.Count == 0) + return Quaternion.Identity; + + if (RotX.Keys.Count == 1) + { + return NormalizeQuaternion(new Quaternion( + (float)RotX.Keys[0].Value, + (float)RotY.Keys[0].Value, + (float)RotZ.Keys[0].Value, + (float)RotW.Keys[0].Value + )); + } + + // Find the two keyframes to interpolate between + int idx0 = -1; + int idx1 = -1; + + for (int i = 0; i < RotX.Keys.Count - 1; i++) + { + if (time >= RotX.Keys[i].Position && time <= RotX.Keys[i + 1].Position) + { + idx0 = i; + idx1 = i + 1; + break; + } + } + + // Handle edge cases + if (idx0 == -1) + { + if (time <= RotX.Keys[0].Position) + { + idx0 = idx1 = 0; + } + else if (time >= RotX.Keys[^1].Position) + { + idx0 = idx1 = RotX.Keys.Count - 1; + } + else + { + // Shouldn't happen, but fallback to first frame + idx0 = idx1 = 0; + } + } + + var key0 = RotX.Keys[idx0]; + var key1 = RotX.Keys[idx1]; + + Quaternion q0 = new ( + (float)RotX.Keys[idx0].Value, + (float)RotY.Keys[idx0].Value, + (float)RotZ.Keys[idx0].Value, + (float)RotW.Keys[idx0].Value + ); + + Quaternion q1 = new ( + (float)RotX.Keys[idx1].Value, + (float)RotY.Keys[idx1].Value, + (float)RotZ.Keys[idx1].Value, + (float)RotW.Keys[idx1].Value + ); + + float t = 0; + if (key1.Position != key0.Position) + { + t = (float)((time - key0.Position) / (key1.Position - key0.Position)); + t = Math.Clamp(t, 0f, 1f); + } + + return Maths.Slerp(q0, q1, t); + } public Double3 EvaluateScaleAt(double time) => new Double3(ScaleX.Evaluate(time), ScaleY.Evaluate(time), ScaleZ.Evaluate(time)); - } + /// + /// Normalize a quaternion + /// + private static Quaternion NormalizeQuaternion(Quaternion q) + { + float length = (float)Math.Sqrt(q.X * q.X + q.Y * q.Y + q.Z * q.Z + q.W * q.W); + + if (length < 0.0001f) + return Quaternion.Identity; + float invLength = 1.0f / length; + return new Quaternion( + q.X * invLength, + q.Y * invLength, + q.Z * invLength, + q.W * invLength + ); + } + } } diff --git a/Prowl.Runtime/Resources/DefaultAssets.cs b/Prowl.Runtime/Resources/DefaultAssets.cs index 3107559c..7c96a44a 100644 --- a/Prowl.Runtime/Resources/DefaultAssets.cs +++ b/Prowl.Runtime/Resources/DefaultAssets.cs @@ -9,6 +9,8 @@ namespace Prowl.Runtime.Resources public enum DefaultShader { Standard, + Unlit, + Line, Invalid, UI, Gizmos, diff --git a/Prowl.Runtime/Resources/Model.cs b/Prowl.Runtime/Resources/Model.cs index 45023f5a..d0d52204 100644 --- a/Prowl.Runtime/Resources/Model.cs +++ b/Prowl.Runtime/Resources/Model.cs @@ -15,6 +15,9 @@ public class Model : EngineObject public List Animations { get; set; } = new(); public float UnitScale { get; set; } = 1.0f; + // This transforms from world space back to mesh/model space + public Float4x4 GlobalInverseTransform { get; set; } = Float4x4.Identity; + public Model(string name) { Name = name; diff --git a/Prowl.Runtime/Resources/Shader.cs b/Prowl.Runtime/Resources/Shader.cs index 040a8ebe..7ab8d9a9 100644 --- a/Prowl.Runtime/Resources/Shader.cs +++ b/Prowl.Runtime/Resources/Shader.cs @@ -131,6 +131,8 @@ public static Shader LoadDefault(DefaultShader shader) string fileName = shader switch { DefaultShader.Standard => "Standard.shader", + DefaultShader.Unlit => "Unlit.shader", + DefaultShader.Line => "Line.shader", DefaultShader.Invalid => "Invalid.shader", DefaultShader.UI => "UI.shader", DefaultShader.Gizmos => "Gizmos.shader", diff --git a/Prowl.sln b/Prowl.sln index d7f6c103..e9b5d72c 100644 --- a/Prowl.sln +++ b/Prowl.sln @@ -35,6 +35,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleCube", "Samples\Simpl EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsCubes", "Samples\PhysicsCubes\PhysicsCubes.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VoxelEngine", "Samples\VoxelEngine\VoxelEngine.csproj", "{FC5AF18E-169D-4BE0-ABE6-9F7D86EB03A0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,6 +95,10 @@ Global {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {FC5AF18E-169D-4BE0-ABE6-9F7D86EB03A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC5AF18E-169D-4BE0-ABE6-9F7D86EB03A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC5AF18E-169D-4BE0-ABE6-9F7D86EB03A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC5AF18E-169D-4BE0-ABE6-9F7D86EB03A0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -110,6 +116,7 @@ Global {DD6F9C77-3B7A-4604-89AD-6275C7A4CCE2} = {33029885-0D82-4603-AC99-8426B0DD3C66} {7EE7D3F1-DE8D-4532-A810-A093D423AEAB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {FC5AF18E-169D-4BE0-ABE6-9F7D86EB03A0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {40438D96-37C1-4E32-A936-74E5406FDE5B} diff --git a/Samples/SimpleCube/Program.cs b/Samples/SimpleCube/Program.cs index 7b3da190..6ae83a6c 100644 --- a/Samples/SimpleCube/Program.cs +++ b/Samples/SimpleCube/Program.cs @@ -2,17 +2,18 @@ // Licensed under the MIT License. See the LICENSE file in the project root for details. // -// SimpleCube Demo - Showcasing Prowl's new Input Action System +// LineRenderer Demo // -// This demo demonstrates: -// - Action-based input with multiple input sources -// - Full keyboard + mouse + gamepad support -// - Input processing (deadzone, normalization, scaling) -// - Seamless switching between input methods +// This demo demonstrates the LineRenderer component with: +// - Billboard rendering (always faces camera) +// - Animated lines with dynamic point updates +// - Color gradients and width variation +// - Looped lines +// - Different texture wrap modes // // Controls: // Movement: WASD / Arrow Keys / Gamepad Left Stick -// Look: Right Mouse + Drag / Gamepad Right Stick +// Look: Mouse Move (when RMB held) / Gamepad Right Stick // Fly Up: E / Gamepad A Button // Fly Down: Q / Gamepad B Button // Sprint: Left Shift / Gamepad Left Stick Click @@ -23,7 +24,7 @@ using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; -using Silk.NET.Input; +using MouseButton = Prowl.Runtime.MouseButton; namespace SimpleCube; @@ -31,7 +32,7 @@ internal class Program { static void Main(string[] args) { - new MyGame().Run("Demo", 1280, 720); + new MyGame().Run("LineRenderer Demo", 1280, 720); } } @@ -39,134 +40,194 @@ public sealed class MyGame : Game { private GameObject cameraGO; private Scene scene; - private ModelRenderer model; // Input Actions private InputActionMap cameraMap = null!; private InputAction moveAction = null!; private InputAction lookAction = null!; + private InputAction lookEnableAction = null!; private InputAction flyUpAction = null!; private InputAction flyDownAction = null!; private InputAction sprintAction = null!; + // Line Renderer Examples + private LineRenderer helix; + private LineRenderer sineWave; + private LineRenderer orbitalRing; + private float time = 0; + public override void Initialize() { scene = new Scene(); - - // Setup Input Actions SetupInputActions(); // Create directional light GameObject lightGO = new("Directional Light"); - var light = lightGO.AddComponent(); + lightGO.AddComponent(); lightGO.Transform.localEulerAngles = new Double3(-80, 5, 0); scene.Add(lightGO); // Create camera cameraGO = new("Main Camera"); cameraGO.tag = "Main Camera"; - cameraGO.Transform.position = new(0, 0, -10); + cameraGO.Transform.position = new(0, 2, -8); var camera = cameraGO.AddComponent(); camera.Depth = -1; camera.HDR = true; - camera.Effects = new List() { - //new ScreenSpaceReflectionEffect(), - //new KawaseBloomEffect(), - //new BokehDepthOfFieldEffect(), + new FXAAEffect(), + new KawaseBloomEffect(), new TonemapperEffect(), }; - scene.Add(cameraGO); - Mesh cube = Mesh.CreateCube(Double3.One); - Material mat = new Material(Shader.LoadDefault(DefaultShader.Standard)); - - GameObject cubeGO = new GameObject("Cube"); - var mr = cubeGO.AddComponent(); - mr.Mesh = cube; - mr.Material = mat; + // Create ground plane + GameObject groundGO = new("Ground"); + var mr = groundGO.AddComponent(); + mr.Mesh = Mesh.CreateCube(Double3.One); + mr.Material = new Material(Shader.LoadDefault(DefaultShader.Standard)); + groundGO.Transform.position = new(0, -3, 0); + groundGO.Transform.localScale = new(20, 1, 20); + scene.Add(groundGO); - cubeGO.Transform.position = new(0, -1, 0); - cubeGO.Transform.localScale = new(10, 1, 10); + // Create Line Renderer Examples + CreateLineExamples(); - scene.Add(cubeGO); - - //var m = Model.LoadFromFile("Banana Man\\scene.gltf"); - var m = Model.LoadFromFile("glTF-Sponza\\Sponza.gltf"); - model = new GameObject("Model").AddComponent(); - model.Model = m; - scene.Add(model.GameObject); Input.SetCursorVisible(false); } - private void SetupInputActions() - { - // Create input action map - cameraMap = new InputActionMap("Camera"); - // Movement action + private void CreateLineExamples() + { + Texture2D texture = Texture2D.LoadDefault(DefaultTexture.Grid); + Material lineMat = new Material(Shader.LoadDefault(DefaultShader.Line)); + lineMat.SetTexture("_MainTex", texture); + + // 1. Animated Helix with width variation + GameObject helixGO = new("Helix"); + helix = helixGO.AddComponent(); + helix.Material = lineMat; + helix.StartWidth = 0.05f; + helix.EndWidth = 0.15f; + helix.TextureTiling = 10f; + helix.StartColor = new Color(1, 0.2f, 0.2f, 1); // Red + helix.EndColor = new Color(1, 0.8f, 0.2f, 1); // Orange + helix.TextureMode = TextureWrapMode.Tile; + + helix.Points = new List(); + for (int i = 0; i <= 80; i++) { - moveAction = cameraMap.AddAction("Move", InputActionType.Value); - moveAction.ExpectedValueType = typeof(Float2); - - // Add WASD composite - var wasdComp = new Vector2CompositeBinding( - InputBinding.CreateKeyBinding(KeyCode.W), - InputBinding.CreateKeyBinding(KeyCode.S), - InputBinding.CreateKeyBinding(KeyCode.A), - InputBinding.CreateKeyBinding(KeyCode.D), - true // normalize - ); - moveAction.AddBinding(wasdComp); - - // Also add gamepad left stick with deadzone - var leftStick = InputBinding.CreateGamepadAxisBinding(0, deviceIndex: 0); - leftStick.Processors.Add(new DeadzoneProcessor(0.15f)); - leftStick.Processors.Add(new NormalizeProcessor()); - moveAction.AddBinding(leftStick); + float t = i / 80f; + float angle = t * MathF.PI * 4; + helix.Points.Add(new Double3( + MathF.Cos(angle) * 0.8f, + t * 3f - 1.5f, + MathF.Sin(angle) * 0.8f + )); } - - // Look action + helixGO.Transform.position = new Double3(-4, 0, 0); + scene.Add(helixGO); + + // 2. Animated Sine Wave + GameObject sineGO = new("SineWave"); + sineWave = sineGO.AddComponent(); + sineWave.Material = lineMat; + sineWave.StartWidth = 0.08f; + sineWave.EndWidth = 0.08f; + sineWave.StartColor = new Color(0.2f, 1, 0.5f, 1); // Green + sineWave.EndColor = new Color(0.2f, 0.5f, 1, 1); // Blue + sineWave.TextureMode = TextureWrapMode.Stretch; + + sineWave.Points = new List(); + for (int i = 0; i <= 60; i++) { - lookAction = cameraMap.AddAction("Look", InputActionType.Value); - lookAction.ExpectedValueType = typeof(Float2); - - var mouse = new DualAxisCompositeBinding(InputBinding.CreateMouseAxisBinding(0), InputBinding.CreateMouseAxisBinding(1)); - mouse.Processors.Add(new ScaleProcessor(0.25f)); // Decreased sensitivity for looking - lookAction.AddBinding(mouse); - - // Gamepad right stick for looking with higher sensitivity and deadzone - var rightStick = InputBinding.CreateGamepadAxisBinding(1, deviceIndex: 0); - rightStick.Processors.Add(new DeadzoneProcessor(0.15f)); - rightStick.Processors.Add(new ScaleProcessor(3.0f)); // Increase sensitivity for looking - lookAction.AddBinding(rightStick); + float t = i / 60f; + sineWave.Points.Add(new Double3( + t * 5f - 2.5f, + MathF.Sin(t * MathF.PI * 3) * 0.8f, + 0 + )); } - - // Fly Up (E key + Gamepad A button) + sineGO.Transform.position = new Double3(0, 1, 3); + scene.Add(sineGO); + + // 3. Looping Orbital Ring + GameObject ringGO = new("OrbitalRing"); + orbitalRing = ringGO.AddComponent(); + orbitalRing.Material = lineMat; + orbitalRing.StartWidth = 0.06f; + orbitalRing.EndWidth = 0.06f; + orbitalRing.Loop = true; + orbitalRing.StartColor = new Color(1, 0.3f, 1, 1); + orbitalRing.EndColor = new Color(1, 0.3f, 1, 1); + orbitalRing.TextureMode = TextureWrapMode.RepeatPerSegment; + + orbitalRing.Points = new List(); + for (int i = 0; i < 48; i++) { - flyUpAction = cameraMap.AddAction("FlyUp", InputActionType.Button); - flyUpAction.AddBinding(KeyCode.E); - flyUpAction.AddBinding(GamepadButton.A); + float angle = (i / 48f) * MathF.PI * 2; + orbitalRing.Points.Add(new Double3( + MathF.Cos(angle) * 1.2f, + MathF.Sin(angle) * 0.3f, + MathF.Sin(angle) * 1.2f + )); } + ringGO.Transform.position = new Double3(4, 1, 0); + ringGO.Transform.localEulerAngles = new Double3(30, 45, 0); + scene.Add(ringGO); + } - // Fly Down (Q key + Gamepad B button) - { - flyDownAction = cameraMap.AddAction("FlyDown", InputActionType.Button); - flyDownAction.AddBinding(KeyCode.Q); - flyDownAction.AddBinding(GamepadButton.B); - } + private void SetupInputActions() + { + cameraMap = new InputActionMap("Camera"); - // Sprint (Shift + Gamepad Left Stick Click) - { - sprintAction = cameraMap.AddAction("Sprint", InputActionType.Button); - sprintAction.AddBinding(KeyCode.ShiftLeft); - sprintAction.AddBinding(GamepadButton.LeftStick); - } + // Movement (WASD + Gamepad) + moveAction = cameraMap.AddAction("Move", InputActionType.Value); + moveAction.ExpectedValueType = typeof(Float2); + moveAction.AddBinding(new Vector2CompositeBinding( + InputBinding.CreateKeyBinding(KeyCode.W), + InputBinding.CreateKeyBinding(KeyCode.S), + InputBinding.CreateKeyBinding(KeyCode.A), + InputBinding.CreateKeyBinding(KeyCode.D), + true + )); + var leftStick = InputBinding.CreateGamepadAxisBinding(0, deviceIndex: 0); + leftStick.Processors.Add(new DeadzoneProcessor(0.15f)); + leftStick.Processors.Add(new NormalizeProcessor()); + moveAction.AddBinding(leftStick); + + // Look enable (RMB) + lookEnableAction = cameraMap.AddAction("LookEnable", InputActionType.Button); + lookEnableAction.AddBinding(MouseButton.Right); + + // Look (Mouse + Gamepad) + lookAction = cameraMap.AddAction("Look", InputActionType.Value); + lookAction.ExpectedValueType = typeof(Float2); + var mouse = new DualAxisCompositeBinding( + InputBinding.CreateMouseAxisBinding(0), + InputBinding.CreateMouseAxisBinding(1)); + mouse.Processors.Add(new ScaleProcessor(0.1f)); + lookAction.AddBinding(mouse); + var rightStick = InputBinding.CreateGamepadAxisBinding(1, deviceIndex: 0); + rightStick.Processors.Add(new DeadzoneProcessor(0.15f)); + rightStick.Processors.Add(new NormalizeProcessor()); + lookAction.AddBinding(rightStick); + + // Fly Up/Down + flyUpAction = cameraMap.AddAction("FlyUp", InputActionType.Button); + flyUpAction.AddBinding(KeyCode.E); + flyUpAction.AddBinding(GamepadButton.A); + flyDownAction = cameraMap.AddAction("FlyDown", InputActionType.Button); + flyDownAction.AddBinding(KeyCode.Q); + flyDownAction.AddBinding(GamepadButton.B); + + // Sprint + sprintAction = cameraMap.AddAction("Sprint", InputActionType.Button); + sprintAction.AddBinding(KeyCode.ShiftLeft); + sprintAction.AddBinding(GamepadButton.LeftStick); - // Register and enable Input.RegisterActionMap(cameraMap); cameraMap.Enable(); } @@ -184,32 +245,59 @@ public override void Render() public override void Update() { scene.Update(); + time += (float)Time.deltaTime; - // Read movement from action (works with WASD, arrows, and gamepad left stick) - Float2 movement = moveAction.ReadValue(); + // Animate helix rotation + if (helix != null) + { + helix.GameObject.Transform.localEulerAngles = new Double3(0, time * 25, 0); + } + + // Animate sine wave + if (sineWave != null) + { + sineWave.Points.Clear(); + for (int i = 0; i <= 60; i++) + { + float t = i / 60f; + sineWave.Points.Add(new Double3( + t * 5f - 2.5f, + MathF.Sin(t * MathF.PI * 3 + time * 2) * 0.8f, + 0 + )); + } + sineWave.MarkDirty(); + } + + // Animate orbital ring rotation + if (orbitalRing != null) + { + orbitalRing.GameObject.Transform.localEulerAngles += new Double3( + 10 * Time.deltaTime, + 20 * Time.deltaTime, + 15 * Time.deltaTime + ); + } - // Calculate speed multiplier (sprint makes you move faster) + // Camera controls + Float2 movement = moveAction.ReadValue(); float speedMultiplier = sprintAction.IsPressed() ? 2.5f : 1.0f; float moveSpeed = 5f * speedMultiplier * (float)Time.deltaTime; - // Apply movement cameraGO.Transform.position += cameraGO.Transform.forward * movement.Y * moveSpeed; cameraGO.Transform.position += cameraGO.Transform.right * movement.X * moveSpeed; - // Vertical movement (fly up/down) float upDown = 0; if (flyUpAction.IsPressed()) upDown += 1; if (flyDownAction.IsPressed()) upDown -= 1; cameraGO.Transform.position += Double3.UnitY * upDown * moveSpeed; - // Look/rotate camera Float2 lookInput = lookAction.ReadValue(); + if (lookEnableAction.IsPressed() || Math.Abs(lookInput.X) > 0.01f || Math.Abs(lookInput.Y) > 0.01f) + { + cameraGO.Transform.localEulerAngles += new Double3(lookInput.Y, lookInput.X, 0); + } - // Apply look rotation from gamepad right stick - float lookSpeed = 100f * (float)Time.deltaTime; - cameraGO.Transform.localEulerAngles += new Double3(lookInput.Y, lookInput.X, 0) * lookSpeed; - - // Unlock cursor on press Escape if (Input.GetKeyDown(KeyCode.Escape)) Input.SetCursorVisible(true); }