diff --git a/camera.go b/camera.go index f46965d..539c8ad 100644 --- a/camera.go +++ b/camera.go @@ -655,7 +655,34 @@ func (camera *Camera) Render(scene *Scene, models ...*Model) { for _, model := range models { - if model.Mesh != nil { + if len(model.DynamicBatchModels) > 0 { + + dynamicDepths := map[*Model]float64{} + + transparent := false + + for _, child := range model.DynamicBatchModels { + + dynamicDepths[child] = camera.WorldToScreen(child.WorldPosition())[2] + + for _, mp := range child.Mesh.MeshParts { + if child.isTransparent(mp) { + transparent = true + } + } + } + + sort.SliceStable(model.DynamicBatchModels, func(i, j int) bool { + return dynamicDepths[model.DynamicBatchModels[i]] > dynamicDepths[model.DynamicBatchModels[j]] + }) + + if transparent { + transparents = append(transparents, renderPair{model, model.Mesh.MeshParts[0]}) + } else { + solids = append(solids, renderPair{model, model.Mesh.MeshParts[0]}) + } + + } else if model.DynamicBatchOwner == nil && model.Mesh != nil { for _, mp := range model.Mesh.MeshParts { if model.isTransparent(mp) { @@ -693,6 +720,8 @@ func (camera *Camera) Render(scene *Scene, models ...*Model) { render := func(rp renderPair) { + startingVertexListIndex := vertexListIndex + model := rp.Model meshPart := rp.MeshPart mat := meshPart.Material @@ -728,8 +757,6 @@ func (camera *Camera) Render(scene *Scene, models ...*Model) { model.ProcessVertices(vpMatrix, camera, meshPart) - vertexListIndex := 0 - backfaceCulling := true if mat != nil { backfaceCulling = mat.BackfaceCulling @@ -820,7 +847,11 @@ func (camera *Camera) Render(scene *Scene, models ...*Model) { // Enforce maximum vertex count; note that this is lazy, which is NOT really a good way of doing this, as you can't really know ahead of time how many triangles may render. if vertexListIndex/3 >= ebiten.MaxIndicesNum/3 { - panic("error in rendering mesh [" + model.Mesh.Name + "] of model [" + model.name + "]. At " + fmt.Sprintf("%d", len(model.Mesh.Triangles)) + " triangles, it exceeds the maximum of 21845 rendered triangles total for one MeshPart; please break up the mesh into multiple MeshParts using materials, or split it up into models") + if model.DynamicBatchOwner == nil { + panic("error in rendering mesh [" + model.Mesh.Name + "] of model [" + model.name + "]. At " + fmt.Sprintf("%d", len(model.Mesh.Triangles)) + " triangles, it exceeds the maximum of 21845 rendered triangles total for one MeshPart; please break up the mesh into multiple MeshParts using materials, or split it up into models") + } else { + panic("error in rendering mesh [" + model.Mesh.Name + "] of model [" + model.name + "] underneath Dynamic merging owner " + model.DynamicBatchOwner.name + ". At " + fmt.Sprintf("%d", model.DynamicBatchOwner.DynamicBatchTriangleCount()) + " triangles, it exceeds the maximum of 21845 rendered triangles total for one MeshPart; please break up the mesh into multiple MeshParts using materials, or split it up into models") + } } colorVertexList[vertexListIndex].DstX = float32(p0[0]) @@ -843,11 +874,11 @@ func (camera *Camera) Render(scene *Scene, models ...*Model) { } - if vertexListIndex == 0 { + if vertexListIndex == startingVertexListIndex { return } - vertexListIndex = 0 + vertexListIndex = startingVertexListIndex for _, tri := range meshPart.sortingTriangles { @@ -965,6 +996,28 @@ func (camera *Camera) Render(scene *Scene, models ...*Model) { indexList[i] = uint16(i) } + } + + flush := func(rp renderPair) { + + if vertexListIndex == 0 { + return + } + + model := rp.Model + meshPart := rp.MeshPart + mat := meshPart.Material + + var img *ebiten.Image + + if mat != nil { + img = mat.Texture + } + + if img == nil { + img = defaultImg + } + // Render the depth map here if camera.RenderDepth { @@ -1068,10 +1121,30 @@ func (camera *Camera) Render(scene *Scene, models ...*Model) { camera.DebugInfo.DrawnTris += vertexListIndex / 3 + vertexListIndex = 0 + } - for _, renderPair := range solids { - render(renderPair) + for _, pair := range solids { + + // Internally, the idea behind dynamic batching is that we simply hold off on flushing until the + // end - this saves a lot of time if we're rendering singular low-poly objects, at the cost of each + // object sharing the same material / object-level properties (color / material blending mode, for + // example). + if dyn := pair.Model.DynamicBatchModels; len(dyn) > 0 { + + for _, merged := range dyn { + for _, part := range merged.Mesh.MeshParts { + render(renderPair{Model: merged, MeshPart: part}) + } + } + + flush(pair) + } else { + render(pair) + flush(pair) + } + } if len(transparents) > 0 { @@ -1080,8 +1153,22 @@ func (camera *Camera) Render(scene *Scene, models ...*Model) { return depths[transparents[i].Model] > depths[transparents[j].Model] }) - for _, renderPair := range transparents { - render(renderPair) + for _, pair := range transparents { + + if dyn := pair.Model.DynamicBatchModels; len(dyn) > 0 { + + for _, merged := range dyn { + for _, part := range merged.Mesh.MeshParts { + render(renderPair{Model: merged, MeshPart: part}) + } + } + + flush(pair) + } else { + render(pair) + flush(pair) + } + } } diff --git a/examples/lighting/main.go b/examples/lighting/main.go index 93e9e0f..5f4e669 100644 --- a/examples/lighting/main.go +++ b/examples/lighting/main.go @@ -59,7 +59,6 @@ func (g *Game) Init() { opt := tetra3d.DefaultGLTFLoadOptions() opt.CameraWidth = g.Width opt.CameraHeight = g.Height - opt.LoadBackfaceCulling = true library, err := tetra3d.LoadGLTFData(gltfData, opt) if err != nil { panic(err) diff --git a/examples/logo/main.go b/examples/logo/main.go index d8fec3e..cd67fb7 100644 --- a/examples/logo/main.go +++ b/examples/logo/main.go @@ -207,7 +207,7 @@ func (g *Game) Draw(screen *ebiten.Image) { if g.DrawDebugText { g.Camera.DrawDebugText(screen, 1, colors.White()) txt := "F1 to toggle this text\nWASD: Move, Mouse: Look\nThe screen object shows what the\ncamera is looking at.\nF5: Toggle depth debug view\nF4: Toggle fullscreen\nESC: Quit" - text.Draw(screen, txt, basicfont.Face7x13, 0, 108, color.RGBA{255, 0, 0, 255}) + text.Draw(screen, txt, basicfont.Face7x13, 0, 120, color.RGBA{200, 200, 200, 255}) } } diff --git a/examples/stress/main.go b/examples/stress/main.go index c0c6dc8..6f41c3c 100644 --- a/examples/stress/main.go +++ b/examples/stress/main.go @@ -17,9 +17,11 @@ import ( "github.com/kvartborg/vector" "github.com/solarlune/tetra3d" "github.com/solarlune/tetra3d/colors" + "golang.org/x/image/font/basicfont" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text" ) //go:embed testimage.png @@ -265,6 +267,8 @@ func (g *Game) Draw(screen *ebiten.Image) { if g.DrawDebugText { g.Camera.DrawDebugText(screen, 1, colors.White()) + txt := "F1 to toggle this text\nWASD: Move, Mouse: Look\nThis is a simple stress test.\nAll of the cubes are statically merged\ntogether into as few render calls as possible.\nF5: Toggle depth debug view\nF4: Toggle fullscreen\nESC: Quit" + text.Draw(screen, txt, basicfont.Face7x13, 0, 120, color.RGBA{200, 200, 200, 255}) } } diff --git a/examples/stress2/character.png b/examples/stress2/character.png new file mode 100755 index 0000000..7d6c25c Binary files /dev/null and b/examples/stress2/character.png differ diff --git a/examples/stress2/main.go b/examples/stress2/main.go index 8885f5d..8eff351 100644 --- a/examples/stress2/main.go +++ b/examples/stress2/main.go @@ -17,14 +17,19 @@ import ( "github.com/kvartborg/vector" "github.com/solarlune/tetra3d" "github.com/solarlune/tetra3d/colors" + "golang.org/x/image/font/basicfont" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text" ) //go:embed testimage.png var testImage []byte +//go:embed character.png +var character []byte + type Game struct { Width, Height int Scene *tetra3d.Scene @@ -38,13 +43,17 @@ type Game struct { DrawDebugDepth bool DrawDebugWireframe bool DrawDebugNormals bool + + Cubes []*tetra3d.Model + + Time float64 } func NewGame() *Game { game := &Game{ - Width: 398, - Height: 224, + Width: 796, + Height: 448, PrevMousePosition: vector.Vector{}, } @@ -55,29 +64,89 @@ func NewGame() *Game { func (g *Game) Init() { + // OK, so in this stress test, we're rendering a lot of individual, low-poly objects (cubes). + + // To make this run faster, we can batch objects together dynamically - unlike static batching, + // this will allow them to move and act individually while also allowing us to render them more + // efficiently as they will render in a single batch. + // However, dynamic batching has some limitations. + + // 1) Dynamically batched Models can't have individual material / object properties (like object + // color, texture, material blend mode, or texture filtering mode). Vertex colors still + // work for fading individual triangles / a dynamically batched Model, though. + + // 2) Dynamically batched objects all count up to a single vertex count, so we can't have + // more than the max number of vertices after batching all objects together into a single + // Model (which at this stage is 65535 vertices, or 21845 triangles). + + // 3) Dynamically batched objects render using the batching object's first meshpart's material + // for rendering. This is to make it predictable as to how all batched objects appear at all times. + + // 4) Because we're batching together dynamically batched objects, they can't write to the depth + // texture individually. This means dynamically batched objects cannot visually intersect with one + // another - they simply draw behind or in front of one another, and so are sorted according to their + // objects' depths in comparison to the camera. + + // While there are some restrictions, the advantages are weighty enough to make it worth it - this + // functionality is very useful for things like drawing a lot of small, simple models, like NPCs, + // particles, or map icons. + + // Create the scene (we're doing this ourselves in this case). g.Scene = tetra3d.NewScene("Test Scene") + // Load the test cube texture. img, _, err := image.Decode(bytes.NewReader(testImage)) if err != nil { panic(err) } - // We can reuse the mesh for all of the models. + // We can reuse the same mesh for all of the models. cubeMesh := tetra3d.NewCube() + // Set up how the cube should appear. mat := cubeMesh.MeshParts[0].Material mat.Shadeless = true mat.Texture = ebiten.NewImageFromImage(img) + // The batched model will hold all of our batching results. The batched objects keep their mesh-level + // details (vertex positions, UV, normals, etc), but will mimic the batching object's material-level + // and object-level properties (texture, material / object color, texture filtering, etc). + + // When a model is dynamically batching other models, the batching model doesn't render. + + // In this example, you will see it as a plane, floating above all the other Cubes before batching. + + planeMesh := tetra3d.NewPlane() + + // Load the character texture. + img, _, err = image.Decode(bytes.NewReader(character)) + if err != nil { + panic(err) + } + + mat = planeMesh.MeshParts[0].Material + mat.Shadeless = true + mat.Texture = ebiten.NewImageFromImage(img) + + batched := tetra3d.NewModel(planeMesh, "DynamicBatching") + batched.Move(0, 4, 0) + batched.Rotate(1, 0, 0, tetra3d.ToRadians(90)) + batched.Rotate(0, 1, 0, tetra3d.ToRadians(180)) + + g.Cubes = []*tetra3d.Model{} + for i := 0; i < 21; i++ { for j := 0; j < 21; j++ { - // Create a new Model, position it, and add it to the cubes slice. + // Create a new Cube, position it, add it to the scene, and add it to the cubes slice. cube := tetra3d.NewModel(cubeMesh, "Cube") - cube.SetLocalPosition(vector.Vector{float64(i * 3), 0, float64(-j * 3)}) + cube.SetLocalPosition(vector.Vector{float64(i) * 1.5, 0, float64(-j * 3)}) g.Scene.Root.AddChildren(cube) + g.Cubes = append(g.Cubes, cube) } } + g.Scene.Root.AddChildren(batched) + g.Camera = tetra3d.NewCamera(g.Width, g.Height) g.Camera.Far = 120 g.Camera.SetLocalPosition(vector.Vector{0, 0, 15}) @@ -93,6 +162,31 @@ func (g *Game) Update() error { moveSpd := 0.1 + tps := 1.0 / 60.0 // 60 ticks per second, regardless of display FPS + + g.Time += tps + + for cubeIndex, cube := range g.Cubes { + wp := cube.WorldPosition() + + wp[1] = math.Sin(g.Time*math.Pi + float64(cubeIndex)) + + cube.SetWorldPosition(wp) + cube.Color.R = float32(cubeIndex) / 100 + cube.Color.G = float32(cubeIndex) / 10 + } + + if inpututil.IsKeyJustPressed(ebiten.Key1) { + dyn := g.Scene.Root.Get("DynamicBatching").(*tetra3d.Model) + + if len(dyn.DynamicBatchModels) == 0 { + dyn.DynamicBatchAdd(g.Cubes...) // Note that Model.DynamicBatchAdd() can return an error if batching the specified objects would push it over the vertex limit. + } else { + dyn.DynamicBatchRemove(g.Cubes...) + } + + } + if ebiten.IsKeyPressed(ebiten.KeyEscape) { err = errors.New("quit") } @@ -244,6 +338,8 @@ func (g *Game) Draw(screen *ebiten.Image) { if g.DrawDebugText { g.Camera.DrawDebugText(screen, 1, colors.White()) + txt := "F1 to toggle this text\nWASD: Move, Mouse: Look\nStress Test 2 - Here, cubes are moving. We can render them\nefficiently by dynamically batching them, though they\nwill mimic the batching object (the character plane) visually - \nthey no longer have their own object color,\ntexture, blend mode, or texture filtering\n(as they all take these properties from the red cube).\nThey also can no longer intersect; rather, they\nwill just draw in front of or behind each other.\n1 Key: Toggle batching cubes together\nF5: Toggle depth debug view\nF4: Toggle fullscreen\nESC: Quit" + text.Draw(screen, txt, basicfont.Face7x13, 0, 140, color.RGBA{200, 200, 200, 255}) } } @@ -254,7 +350,7 @@ func (g *Game) Layout(w, h int) (int, int) { func main() { - ebiten.SetWindowTitle("Tetra3d Test - Stress Test") + ebiten.SetWindowTitle("Tetra3d Test - Stress Test 2") ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) game := NewGame() diff --git a/gltf.go b/gltf.go index 1a58c3c..29170e9 100644 --- a/gltf.go +++ b/gltf.go @@ -20,7 +20,6 @@ import ( type GLTFLoadOptions struct { CameraWidth, CameraHeight int // Width and height of loaded Cameras. Defaults to 1920x1080. CameraDepth bool // If cameras should render depth or not - LoadBackfaceCulling bool // If backface culling settings for materials should be loaded. Backface culling defaults to off in Blender (which is annoying, and so may be bypassed here). DefaultToAutoTransparency bool // If DefaultToAutoTransparency is true, then opaque materials become Auto transparent materials in Tetra3D. // DependentLibraryResolver is a function that takes a relative path (string) to the blend file representing the dependent Library that the loading // Library requires. This function should return a reference to the dependent Library; if it doesn't, the linked objects from the dependent Library @@ -40,7 +39,6 @@ func DefaultGLTFLoadOptions() *GLTFLoadOptions { CameraHeight: 1080, CameraDepth: true, DefaultToAutoTransparency: true, - LoadBackfaceCulling: true, } } @@ -155,9 +153,7 @@ func LoadGLTFData(data []byte, gltfLoadOptions *GLTFLoadOptions) (*Library, erro newMat := NewMaterial(gltfMat.Name) newMat.library = library - if gltfLoadOptions.LoadBackfaceCulling { - newMat.BackfaceCulling = !gltfMat.DoubleSided - } + newMat.BackfaceCulling = !gltfMat.DoubleSided if texture := gltfMat.PBRMetallicRoughness.BaseColorTexture; texture != nil { if exportedTextures { diff --git a/model.go b/model.go index 4b65a2d..56c9ff4 100644 --- a/model.go +++ b/model.go @@ -1,6 +1,7 @@ package tetra3d import ( + "errors" "math" "sort" "time" @@ -19,6 +20,9 @@ type Model struct { ColorBlendingFunc func(model *Model, meshPart *MeshPart) ebiten.ColorM // The blending function used to color the Model; by default, it basically modulates the model by the color. BoundingSphere *BoundingSphere + DynamicBatchModels []*Model // Models that are dynamically merged into this one. + DynamicBatchOwner *Model + Skinned bool // If the model is skinned and this is enabled, the model will tranform its vertices to match the skinning armature (Model.SkinRoot). SkinRoot INode // The root node of the armature skinning this Model. skinMatrix Matrix4 @@ -41,12 +45,13 @@ var defaultColorBlendingFunc = func(model *Model, meshPart *MeshPart) ebiten.Col func NewModel(mesh *Mesh, name string) *Model { model := &Model{ - Node: NewNode(name), - Mesh: mesh, - FrustumCulling: true, - Color: NewColor(1, 1, 1, 1), - ColorBlendingFunc: defaultColorBlendingFunc, - skinMatrix: NewMatrix4(), + Node: NewNode(name), + Mesh: mesh, + FrustumCulling: true, + Color: NewColor(1, 1, 1, 1), + ColorBlendingFunc: defaultColorBlendingFunc, + skinMatrix: NewMatrix4(), + DynamicBatchModels: []*Model{}, } if mesh != nil { @@ -70,6 +75,8 @@ func (model *Model) Clone() INode { newModel.FrustumCulling = model.FrustumCulling newModel.visible = model.visible newModel.Color = model.Color.Clone() + newModel.DynamicBatchModels = append(newModel.DynamicBatchModels, model.DynamicBatchModels...) + newModel.DynamicBatchOwner = model.DynamicBatchOwner newModel.Skinned = model.Skinned newModel.SkinRoot = model.SkinRoot @@ -92,6 +99,10 @@ func (model *Model) Clone() INode { // would impact the Model. func (model *Model) Transform() Matrix4 { + if model.Mesh == nil { + return NewEmptyMatrix4() + } + if model.isTransformDirty { wp := model.WorldPosition() @@ -130,11 +141,92 @@ func (model *Model) Transform() Matrix4 { } -// Merge merges the provided models into the calling Model. You can use this to merge several objects initially dynamically placed into the calling Model's mesh, -// thereby saving on draw calls. Note that models are merged into MeshParts (saving draw calls) based on maximum vertex count and shared materials (so to get any -// benefit from merging, ensure the merged models share materials; if they all have unique materials, they will be turned into individual MeshParts, thereby forcing multiple -// draw calls). +func (model *Model) modelAlreadyDynamicallyBatched(batchedModel *Model) bool { + for _, m := range model.DynamicBatchModels { + if m == batchedModel { + return true + } + } + return false +} + +// DynamicBatchAdd adds the provided models to the calling Model's dynamic batch. Note that unlike StaticMerge(), DynamicBatchAdd works by simply +// rendering the batched models using the calling Model's first MeshPart's material. By dynamically batching models together, this allows us to +// not flush between rendering multiple Models, saving a lot of render time, particularly if rendering many low-poly, individual models that have +// very little variance (i.e. if they all share a single texture). +// For more information, see this Wiki page on batching / merging: https://github.com/SolarLune/Tetra3d/wiki/Merging-and-Batching-Draw-Calls +func (model *Model) DynamicBatchAdd(batchedModels ...*Model) error { + + for _, other := range batchedModels { + + if model == other || model.modelAlreadyDynamicallyBatched(other) { + continue + } + + triCount := model.DynamicBatchTriangleCount() + + if triCount+len(other.Mesh.Triangles) > maxTriangleCount { + return errors.New("too many triangles in dynamic merge") + } + + // for _, otherPart := range other.Mesh.MeshParts { + + // var targetPart *MeshPart + + // for _, mp := range model.Mesh.MeshParts { + // if mp.Material == otherPart.Material && mp.TriangleCount()+otherPart.TriangleCount() < maxTriangleCount { + // targetPart = mp + // break + // } + // } + + // if targetPart == nil { + // targetPart = model.Mesh.AddMeshPart(otherPart.Material) + // } + + // // Here, we'll batch meshparts together, using its existing mesh parts if the materials match + // // and if adding in the triangles wouldn't exceed the maximum triangle count (21845 in a single draw call). + + // } + + model.DynamicBatchModels = append(model.DynamicBatchModels, other) + other.DynamicBatchOwner = model + + } + + return nil + +} + +// DynamicBatchRemove removes the specified batched Models from the calling Model's dynamic batch slice. +func (model *Model) DynamicBatchRemove(batched ...*Model) { + for _, m := range batched { + for i, existing := range model.DynamicBatchModels { + if existing == m { + model.DynamicBatchModels[i] = nil + model.DynamicBatchModels = append(model.DynamicBatchModels[:i], model.DynamicBatchModels[i+1:]...) + m.DynamicBatchOwner = nil + break + } + } + } +} + +// DynamicBatchTriangleCount returns the total number of triangles of Models in the calling Model's dynamic batch. +func (model *Model) DynamicBatchTriangleCount() int { + count := 0 + for _, child := range model.DynamicBatchModels { + count += len(child.Mesh.Triangles) + } + return count +} +// Merge statically merges the provided models into the calling Model's mesh, such that their vertex properties (position, normal, UV, etc) are part of the calling Model's Mesh. +// You can use this to merge several objects initially dynamically placed into the calling Model's mesh, thereby pulling back to a single draw call. Note that models are merged into MeshParts +// (saving draw calls) based on maximum vertex count and shared materials (so to get any benefit from merging, ensure the merged models share materials; if they all have unique +// materials, they will be turned into individual MeshParts, thereby forcing multiple draw calls). Also note that as the name suggests, this is static merging, which means that +// after merging, the new vertices are static - part of the merging Model. +// For more information, see this Wiki page on batching / merging: https://github.com/SolarLune/Tetra3d/wiki/Merging-and-Batching-Draw-Calls func (model *Model) Merge(models ...*Model) { totalSize := 0 @@ -177,7 +269,7 @@ func (model *Model) Merge(models ...*Model) { var targetPart *MeshPart for _, mp := range model.Mesh.MeshParts { - if mp.Material == otherPart.Material && mp.TriangleCount()+otherPart.TriangleCount() < ebiten.MaxIndicesNum/3 { + if mp.Material == otherPart.Material && mp.TriangleCount()+otherPart.TriangleCount() < maxTriangleCount { targetPart = mp break } @@ -185,7 +277,6 @@ func (model *Model) Merge(models ...*Model) { if targetPart == nil { targetPart = model.Mesh.AddMeshPart(otherPart.Material) - // targetPart.allocateSortingBuffer(ebiten.MaxIndicesNum) } verts := []VertexInfo{} diff --git a/tetra3d.go b/tetra3d.go index c7f33b5..841b7ff 100644 --- a/tetra3d.go +++ b/tetra3d.go @@ -13,6 +13,9 @@ var defaultImg = ebiten.NewImage(1, 1) var colorVertexList = make([]ebiten.Vertex, ebiten.MaxIndicesNum) var depthVertexList = make([]ebiten.Vertex, ebiten.MaxIndicesNum) var indexList = make([]uint16, ebiten.MaxIndicesNum) +var vertexListIndex = 0 + +const maxTriangleCount = 21845 func init() { defaultImg.Fill(color.White)