Adjusts camera distance multiplier from 1.5 to 1.25 when enabled, reducing empty space around character while leaving room for cosmetics.
390 lines
10 KiB
Go
390 lines
10 KiB
Go
package render
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"math"
|
|
|
|
"github.com/fogleman/fauxgl"
|
|
"github.com/qmuntal/gltf"
|
|
)
|
|
|
|
// GLBToMesh converts GLB bytes to a fauxgl mesh with texture
|
|
func GLBToMesh(glbBytes []byte, atlasImage image.Image) (*fauxgl.Mesh, error) {
|
|
doc := new(gltf.Document)
|
|
if err := gltf.NewDecoder(bytes.NewReader(glbBytes)).Decode(doc); err != nil {
|
|
return nil, fmt.Errorf("parsing GLB: %w", err)
|
|
}
|
|
|
|
mesh := fauxgl.NewEmptyMesh()
|
|
|
|
// Process all nodes in the scene
|
|
if len(doc.Scenes) == 0 || len(doc.Scenes[0].Nodes) == 0 {
|
|
return nil, fmt.Errorf("GLB has no scene nodes")
|
|
}
|
|
|
|
// Build node transforms
|
|
for _, nodeIdx := range doc.Scenes[0].Nodes {
|
|
if err := processNode(doc, int(nodeIdx), fauxgl.Identity(), mesh, atlasImage); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return mesh, nil
|
|
}
|
|
|
|
func processNode(doc *gltf.Document, nodeIdx int, parentTransform fauxgl.Matrix, mesh *fauxgl.Mesh, atlasImage image.Image) error {
|
|
node := doc.Nodes[nodeIdx]
|
|
|
|
// Build local transform in TRS order: Translation * Rotation * Scale
|
|
localTransform := fauxgl.Identity()
|
|
|
|
// Apply scale first (rightmost in matrix multiplication)
|
|
if node.Scale != [3]float64{1, 1, 1} && node.Scale != [3]float64{0, 0, 0} {
|
|
localTransform = fauxgl.Scale(fauxgl.V(node.Scale[0], node.Scale[1], node.Scale[2])).Mul(localTransform)
|
|
}
|
|
|
|
// Apply rotation
|
|
if node.Rotation != [4]float64{0, 0, 0, 1} {
|
|
qx, qy, qz, qw := node.Rotation[0], node.Rotation[1], node.Rotation[2], node.Rotation[3]
|
|
R := quaternionToMatrix(qx, qy, qz, qw)
|
|
localTransform = R.Mul(localTransform)
|
|
}
|
|
|
|
// Apply translation last (leftmost)
|
|
if node.Translation != [3]float64{0, 0, 0} {
|
|
localTransform = fauxgl.Translate(fauxgl.V(node.Translation[0], node.Translation[1], node.Translation[2])).Mul(localTransform)
|
|
}
|
|
|
|
// World transform = parent * local
|
|
worldTransform := parentTransform.Mul(localTransform)
|
|
|
|
// Process mesh if present
|
|
if node.Mesh != nil {
|
|
gltfMesh := doc.Meshes[*node.Mesh]
|
|
for _, prim := range gltfMesh.Primitives {
|
|
if err := processPrimitive(doc, prim, worldTransform, mesh, atlasImage); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process children
|
|
for _, childIdx := range node.Children {
|
|
if err := processNode(doc, int(childIdx), worldTransform, mesh, atlasImage); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func processPrimitive(doc *gltf.Document, prim *gltf.Primitive, transform fauxgl.Matrix, mesh *fauxgl.Mesh, atlasImage image.Image) error {
|
|
// Get position accessor
|
|
posAccessorIdx, ok := prim.Attributes[gltf.POSITION]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
posAccessor := doc.Accessors[posAccessorIdx]
|
|
positions, err := readVec3Accessor(doc, posAccessor)
|
|
if err != nil {
|
|
return fmt.Errorf("reading positions: %w", err)
|
|
}
|
|
|
|
// Get UV accessor (optional)
|
|
var uvs [][2]float32
|
|
if uvAccessorIdx, ok := prim.Attributes[gltf.TEXCOORD_0]; ok {
|
|
uvAccessor := doc.Accessors[uvAccessorIdx]
|
|
uvs, err = readVec2Accessor(doc, uvAccessor)
|
|
if err != nil {
|
|
return fmt.Errorf("reading UVs: %w", err)
|
|
}
|
|
}
|
|
|
|
// Get indices
|
|
var indices []uint32
|
|
if prim.Indices != nil {
|
|
indicesAccessor := doc.Accessors[*prim.Indices]
|
|
indices, err = readIndicesAccessor(doc, indicesAccessor)
|
|
if err != nil {
|
|
return fmt.Errorf("reading indices: %w", err)
|
|
}
|
|
} else {
|
|
for i := 0; i < len(positions); i++ {
|
|
indices = append(indices, uint32(i))
|
|
}
|
|
}
|
|
|
|
// Build triangles
|
|
for i := 0; i < len(indices); i += 3 {
|
|
i0, i1, i2 := indices[i], indices[i+1], indices[i+2]
|
|
|
|
p0 := positions[i0]
|
|
p1 := positions[i1]
|
|
p2 := positions[i2]
|
|
|
|
v0 := fauxgl.V(float64(p0[0]), float64(p0[1]), float64(p0[2]))
|
|
v1 := fauxgl.V(float64(p1[0]), float64(p1[1]), float64(p1[2]))
|
|
v2 := fauxgl.V(float64(p2[0]), float64(p2[1]), float64(p2[2]))
|
|
|
|
// Transform positions
|
|
v0 = transform.MulPosition(v0)
|
|
v1 = transform.MulPosition(v1)
|
|
v2 = transform.MulPosition(v2)
|
|
|
|
tri := fauxgl.Triangle{
|
|
V1: fauxgl.Vertex{Position: v0, Color: fauxgl.White},
|
|
V2: fauxgl.Vertex{Position: v1, Color: fauxgl.White},
|
|
V3: fauxgl.Vertex{Position: v2, Color: fauxgl.White},
|
|
}
|
|
|
|
// Add UVs if available
|
|
if len(uvs) > 0 {
|
|
uv0 := uvs[i0]
|
|
uv1 := uvs[i1]
|
|
uv2 := uvs[i2]
|
|
|
|
tri.V1.Texture = fauxgl.V(float64(uv0[0]), 1.0-float64(uv0[1]), 0)
|
|
tri.V2.Texture = fauxgl.V(float64(uv1[0]), 1.0-float64(uv1[1]), 0)
|
|
tri.V3.Texture = fauxgl.V(float64(uv2[0]), 1.0-float64(uv2[1]), 0)
|
|
}
|
|
|
|
// Compute normal from transformed vertices
|
|
edge1 := v1.Sub(v0)
|
|
edge2 := v2.Sub(v0)
|
|
normal := edge1.Cross(edge2).Normalize()
|
|
tri.V1.Normal = normal
|
|
tri.V2.Normal = normal
|
|
tri.V3.Normal = normal
|
|
|
|
mesh.Triangles = append(mesh.Triangles, &tri)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func readVec3Accessor(doc *gltf.Document, accessor *gltf.Accessor) ([][3]float32, error) {
|
|
if accessor.BufferView == nil {
|
|
return nil, fmt.Errorf("accessor has no buffer view")
|
|
}
|
|
|
|
bv := doc.BufferViews[*accessor.BufferView]
|
|
buf := doc.Buffers[bv.Buffer]
|
|
|
|
data := buf.Data[bv.ByteOffset+accessor.ByteOffset:]
|
|
stride := bv.ByteStride
|
|
if stride == 0 {
|
|
stride = 12
|
|
}
|
|
|
|
count := int(accessor.Count)
|
|
result := make([][3]float32, count)
|
|
for i := 0; i < count; i++ {
|
|
offset := i * int(stride)
|
|
result[i][0] = readFloat32LE(data[offset:])
|
|
result[i][1] = readFloat32LE(data[offset+4:])
|
|
result[i][2] = readFloat32LE(data[offset+8:])
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func readVec2Accessor(doc *gltf.Document, accessor *gltf.Accessor) ([][2]float32, error) {
|
|
if accessor.BufferView == nil {
|
|
return nil, fmt.Errorf("accessor has no buffer view")
|
|
}
|
|
|
|
bv := doc.BufferViews[*accessor.BufferView]
|
|
buf := doc.Buffers[bv.Buffer]
|
|
|
|
data := buf.Data[bv.ByteOffset+accessor.ByteOffset:]
|
|
stride := bv.ByteStride
|
|
if stride == 0 {
|
|
stride = 8
|
|
}
|
|
|
|
count := int(accessor.Count)
|
|
result := make([][2]float32, count)
|
|
for i := 0; i < count; i++ {
|
|
offset := i * int(stride)
|
|
result[i][0] = readFloat32LE(data[offset:])
|
|
result[i][1] = readFloat32LE(data[offset+4:])
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func readIndicesAccessor(doc *gltf.Document, accessor *gltf.Accessor) ([]uint32, error) {
|
|
if accessor.BufferView == nil {
|
|
return nil, fmt.Errorf("accessor has no buffer view")
|
|
}
|
|
|
|
bv := doc.BufferViews[*accessor.BufferView]
|
|
buf := doc.Buffers[bv.Buffer]
|
|
|
|
data := buf.Data[bv.ByteOffset+accessor.ByteOffset:]
|
|
count := int(accessor.Count)
|
|
result := make([]uint32, count)
|
|
|
|
switch accessor.ComponentType {
|
|
case gltf.ComponentUbyte:
|
|
for i := 0; i < count; i++ {
|
|
result[i] = uint32(data[i])
|
|
}
|
|
case gltf.ComponentUshort:
|
|
for i := 0; i < count; i++ {
|
|
result[i] = uint32(readUint16LE(data[i*2:]))
|
|
}
|
|
case gltf.ComponentUint:
|
|
for i := 0; i < count; i++ {
|
|
result[i] = readUint32LE(data[i*4:])
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported index component type: %v", accessor.ComponentType)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func readFloat32LE(data []byte) float32 {
|
|
bits := uint32(data[0]) | uint32(data[1])<<8 | uint32(data[2])<<16 | uint32(data[3])<<24
|
|
return math.Float32frombits(bits)
|
|
}
|
|
|
|
func readUint16LE(data []byte) uint16 {
|
|
return uint16(data[0]) | uint16(data[1])<<8
|
|
}
|
|
|
|
func readUint32LE(data []byte) uint32 {
|
|
return uint32(data[0]) | uint32(data[1])<<8 | uint32(data[2])<<16 | uint32(data[3])<<24
|
|
}
|
|
|
|
// AlphaTestShader is a texture shader with alpha cutoff
|
|
type AlphaTestShader struct {
|
|
Matrix fauxgl.Matrix
|
|
Texture fauxgl.Texture
|
|
AlphaCutoff float64
|
|
}
|
|
|
|
func NewAlphaTestShader(matrix fauxgl.Matrix, texture fauxgl.Texture, alphaCutoff float64) *AlphaTestShader {
|
|
return &AlphaTestShader{matrix, texture, alphaCutoff}
|
|
}
|
|
|
|
func (s *AlphaTestShader) Vertex(v fauxgl.Vertex) fauxgl.Vertex {
|
|
v.Output = s.Matrix.MulPositionW(v.Position)
|
|
return v
|
|
}
|
|
|
|
func (s *AlphaTestShader) Fragment(v fauxgl.Vertex) fauxgl.Color {
|
|
color := s.Texture.Sample(v.Texture.X, v.Texture.Y)
|
|
if color.A < s.AlphaCutoff {
|
|
return fauxgl.Discard
|
|
}
|
|
return color
|
|
}
|
|
|
|
func quaternionToMatrix(x, y, z, w float64) fauxgl.Matrix {
|
|
n := math.Sqrt(x*x + y*y + z*z + w*w)
|
|
if n > 0 {
|
|
x /= n
|
|
y /= n
|
|
z /= n
|
|
w /= n
|
|
}
|
|
|
|
xx := x * x
|
|
yy := y * y
|
|
zz := z * z
|
|
xy := x * y
|
|
xz := x * z
|
|
yz := y * z
|
|
wx := w * x
|
|
wy := w * y
|
|
wz := w * z
|
|
|
|
return fauxgl.Matrix{
|
|
1 - 2*(yy+zz), 2 * (xy - wz), 2 * (xz + wy), 0,
|
|
2 * (xy + wz), 1 - 2*(xx+zz), 2 * (yz - wx), 0,
|
|
2 * (xz - wy), 2 * (yz + wx), 1 - 2*(xx+yy), 0,
|
|
0, 0, 0, 1,
|
|
}
|
|
}
|
|
|
|
// RenderScene renders a mesh with the given parameters
|
|
func RenderScene(mesh *fauxgl.Mesh, atlasImage image.Image, rotationY float64, width, height int, bgColor color.Color, autoZoom bool) image.Image {
|
|
context := fauxgl.NewContext(width, height)
|
|
context.Cull = fauxgl.CullNone
|
|
context.AlphaBlend = false
|
|
|
|
r, g, b, a := bgColor.RGBA()
|
|
if a == 0 {
|
|
context.ClearColor = fauxgl.Transparent
|
|
} else {
|
|
context.ClearColor = fauxgl.Color{
|
|
R: float64(r) / 65535.0,
|
|
G: float64(g) / 65535.0,
|
|
B: float64(b) / 65535.0,
|
|
A: float64(a) / 65535.0,
|
|
}
|
|
}
|
|
context.ClearColorBuffer()
|
|
context.ClearDepthBuffer()
|
|
|
|
box := mesh.BoundingBox()
|
|
modelCenter := box.Center()
|
|
modelSize := box.Size()
|
|
|
|
aspect := float64(width) / float64(height)
|
|
fovy := 30.0
|
|
near := 0.1
|
|
far := 100.0
|
|
|
|
maxDim := math.Max(modelSize.X, math.Max(modelSize.Y, modelSize.Z))
|
|
multiplier := 1.5
|
|
if autoZoom {
|
|
multiplier = 1.25
|
|
}
|
|
cameraDistance := maxDim / (2 * math.Tan(fauxgl.Radians(fovy/2))) * multiplier
|
|
|
|
eye := fauxgl.V(modelCenter.X, modelCenter.Y, modelCenter.Z+cameraDistance)
|
|
center := modelCenter
|
|
up := fauxgl.V(0, 1, 0)
|
|
|
|
modelMatrix := fauxgl.Rotate(fauxgl.V(0, 1, 0), fauxgl.Radians(rotationY))
|
|
viewMatrix := fauxgl.LookAt(eye, center, up)
|
|
projMatrix := fauxgl.Perspective(fovy, aspect, near, far)
|
|
matrix := projMatrix.Mul(viewMatrix).Mul(modelMatrix)
|
|
|
|
var shader fauxgl.Shader
|
|
if atlasImage != nil {
|
|
shader = NewAlphaTestShader(matrix, fauxgl.NewImageTexture(atlasImage), 0.05)
|
|
} else {
|
|
shader = fauxgl.NewSolidColorShader(matrix, fauxgl.HexColor("#CCCCCC"))
|
|
}
|
|
|
|
context.Shader = shader
|
|
context.DrawMesh(mesh)
|
|
|
|
return context.Image()
|
|
}
|
|
|
|
// ParseHexColor parses a hex color string like "#RRGGBB" or "transparent"
|
|
func ParseHexColor(hex string) (color.Color, error) {
|
|
if hex == "transparent" || hex == "" {
|
|
return color.RGBA{0, 0, 0, 0}, nil
|
|
}
|
|
|
|
if len(hex) != 7 || hex[0] != '#' {
|
|
return nil, fmt.Errorf("invalid hex color: %s (expected #RRGGBB)", hex)
|
|
}
|
|
|
|
var r, g, b uint8
|
|
_, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing hex color %s: %w", hex, err)
|
|
}
|
|
|
|
return color.RGBA{r, g, b, 255}, nil
|
|
}
|