386 lines
10 KiB
Go
386 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) 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))
|
|
cameraDistance := maxDim / (2 * math.Tan(fauxgl.Radians(fovy/2))) * 1.5
|
|
|
|
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
|
|
}
|