From 94b475af559e221a8dca163cbf594822ede2ec97 Mon Sep 17 00:00:00 2001 From: JackGamesFTW <28160910+JackGamesFTW@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:55:03 +0100 Subject: [PATCH 1/7] pre-allocate geometry slices in GLB exporter --- pkg/export/glb.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/pkg/export/glb.go b/pkg/export/glb.go index c04e57e..eb3f1c9 100644 --- a/pkg/export/glb.go +++ b/pkg/export/glb.go @@ -286,10 +286,11 @@ func (e *GLBExporter) createBoxMesh(node *blockymodel.Node, materialIdx uint32, flipY := stretchY < 0 flipZ := stretchZ < 0 - var positions [][3]float32 - var normals [][3]float32 - var uvs [][2]float32 - var indices []uint16 + // Pre-allocate: max 6 faces × 4 verts = 24 verts, 6 faces × 6 indices = 36 + positions := make([][3]float32, 0, 24) + normals := make([][3]float32, 0, 24) + uvs := make([][2]float32, 0, 24) + indices := make([]uint16, 0, 36) // Blockbench face order: east, west, up, down, south, north // This maps to: X+, X-, Y+, Y-, Z+, Z- @@ -697,11 +698,11 @@ func (e *GLBExporter) createQuadMesh(node *blockymodel.Node, materialIdx uint32, } } - var normalsArr [][3]float32 - var uvsArr [][2]float32 + normalsArr := make([][3]float32, 4) + uvsArr := make([][2]float32, 4) for i := 0; i < 4; i++ { - normalsArr = append(normalsArr, flippedNormal) - uvsArr = append(uvsArr, uvs[i]) + normalsArr[i] = flippedNormal + uvsArr[i] = uvs[i] } // Triangle indices - reverse winding if odd number of flips From 1375e284d411e707848f364333c978065808d1dd Mon Sep 17 00:00:00 2001 From: JackGamesFTW <28160910+JackGamesFTW@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:55:20 +0100 Subject: [PATCH 2/7] add new character test file --- characters/maz_test.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 characters/maz_test.json diff --git a/characters/maz_test.json b/characters/maz_test.json new file mode 100644 index 0000000..438c8f6 --- /dev/null +++ b/characters/maz_test.json @@ -0,0 +1,18 @@ +{ + "bodyCharacteristic": "Default.48", + "underwear": "Bra.Black", + "face": "Face_Scar", + "ears": "Elf_Ears_Large_Down", + "mouth": "Mouth_Tiny", + "haircut": "Long.White", + "eyebrows": "Plucked.White", + "eyes": "Square_Eyes.Orange", + "pants": "LongDungarees.Black.LongDungareesSlacks", + "overpants": "KneePads.Iron_Black", + "undertop": "Top_Wasteland_Marauder.Black", + "overtop": "TrenchCoat.Orange", + "shoes": "ScavenverLeatherBoots.Brown", + "headAccessory": "WitchHat.Orange", + "earAccessory": "DoubleEarrings.Iron_Black.Right", + "gloves": "LongGloves_Popstar.Black" +} \ No newline at end of file From b75066f92653cee09c96e84e48b8651745be245f Mon Sep 17 00:00:00 2001 From: JackGamesFTW <28160910+JackGamesFTW@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:56:24 +0100 Subject: [PATCH 3/7] simplify atlas packing to single-pass --- pkg/texture/atlas.go | 120 +++++-------------------------------------- 1 file changed, 13 insertions(+), 107 deletions(-) diff --git a/pkg/texture/atlas.go b/pkg/texture/atlas.go index 405a714..a2facb7 100644 --- a/pkg/texture/atlas.go +++ b/pkg/texture/atlas.go @@ -4,6 +4,7 @@ import ( "fmt" "image" "image/draw" + "math" "sort" ) @@ -54,94 +55,18 @@ func PackAtlas(textures []*TintedTexture, padding int) (*Atlas, error) { } } - // Try different aspect ratios to find the most compact atlas - // Start with a square-ish size based on area, then try different widths - sideLen := int(float64(totalArea)*1.2) / maxTexHeight - if sideLen < maxTexWidth { - sideLen = maxTexWidth + // Single-pass: use sqrt(totalArea) as width, clamped to maxTexWidth + width := int(math.Sqrt(float64(totalArea) * 1.2)) + if width < maxTexWidth { + width = maxTexWidth } - - // Try widths from minimum needed up to wider options - bestAtlas := (*Atlas)(nil) - bestArea := int(^uint(0) >> 1) // max int - - // Try different widths: start narrow, go wider - for width := maxTexWidth; width <= sideLen*4; width += maxTexWidth / 2 { - if width < maxTexWidth { - width = maxTexWidth - } - // Try with a tall enough height - maxHeight := totalArea/width + maxTexHeight*len(sorted) - atlas := tryPackTight(sorted, width, maxHeight, padding) - if atlas != nil { - area := atlas.Width * atlas.Height - if area < bestArea { - bestArea = area - bestAtlas = atlas - } - } - } - - if bestAtlas == nil { + maxHeight := totalArea/width + maxTexHeight*len(sorted) + atlas := tryPackTight(sorted, width, maxHeight, padding) + if atlas == nil { return nil, fmt.Errorf("failed to pack textures into atlas") } - return bestAtlas, nil -} - -func tryPack(textures []*TintedTexture, width, height, padding int) *Atlas { - atlas := &Atlas{ - Image: image.NewRGBA(image.Rect(0, 0, width, height)), - Entries: make(map[string]*AtlasEntry), - Width: width, - Height: height, - } - - // Shelf packing: place textures in rows - shelfY := 0 - shelfHeight := 0 - currentX := 0 - - for _, tex := range textures { - bounds := tex.Image.Bounds() - texW, texH := bounds.Dx(), bounds.Dy() - - // Check if texture fits on current shelf - if currentX+texW+padding > width { - // Move to next shelf - shelfY += shelfHeight + padding - shelfHeight = 0 - currentX = 0 - } - - // Check if we've run out of vertical space - if shelfY+texH+padding > height { - return nil // Doesn't fit - } - - // Place texture - entry := &AtlasEntry{ - Name: tex.Name, - Image: tex.Image, - X: currentX, - Y: shelfY, - Width: texW, - Height: texH, - } - atlas.Entries[tex.Name] = entry - - // Draw texture onto atlas - destRect := image.Rect(currentX, shelfY, currentX+texW, shelfY+texH) - draw.Draw(atlas.Image, destRect, tex.Image, bounds.Min, draw.Over) - - // Update shelf tracking - currentX += texW + padding - if texH > shelfHeight { - shelfHeight = texH - } - } - - return atlas + return atlas, nil } // tryPackTight packs textures and crops to actual content bounds @@ -354,32 +279,13 @@ func PackAtlasWithBase(textures []*TintedTexture, padding int) (*Atlas, error) { return remaining[i].Image.Bounds().Dy() > remaining[j].Image.Bounds().Dy() }) - // Try different widths to find optimal packing - // Start with base width, try up to 2x base width - bestAtlas := (*Atlas)(nil) - bestArea := int(^uint(0) >> 1) - - for targetWidth := baseW; targetWidth <= baseW*2; targetWidth += baseW / 4 { - atlas := tryPackWithBase(baseTex, remaining, targetWidth, padding) - if atlas != nil { - area := atlas.Width * atlas.Height - if area < bestArea { - bestArea = area - bestAtlas = atlas - } - } - } - - if bestAtlas == nil { - // Fallback: use wider atlas - bestAtlas = tryPackWithBase(baseTex, remaining, baseW*4, padding) - } - - if bestAtlas == nil { + // Single-pass with base width + atlas := tryPackWithBase(baseTex, remaining, baseW, padding) + if atlas == nil { return nil, fmt.Errorf("failed to pack textures into atlas") } - return bestAtlas, nil + return atlas, nil } func tryPackWithBase(baseTex *TintedTexture, remaining []*TintedTexture, targetWidth, padding int) *Atlas { From b05ed1c4648ff166b73a97f6b45738bd0be2a730 Mon Sep 17 00:00:00 2001 From: JackGamesFTW <28160910+JackGamesFTW@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:58:50 +0100 Subject: [PATCH 4/7] replace JSON-based clone with direct struct deep copy --- pkg/blockymodel/io.go | 115 +++++++++++++++++++++++++++++++++++------- 1 file changed, 98 insertions(+), 17 deletions(-) diff --git a/pkg/blockymodel/io.go b/pkg/blockymodel/io.go index 4fc72e8..6de4470 100644 --- a/pkg/blockymodel/io.go +++ b/pkg/blockymodel/io.go @@ -35,32 +35,113 @@ func Save(model *BlockyModel, path string) error { return nil } -// Clone creates a deep copy of a BlockyModel via JSON marshal/unmarshal +// Clone creates a deep copy of a BlockyModel func Clone(model *BlockyModel) (*BlockyModel, error) { - data, err := json.Marshal(model) - if err != nil { - return nil, fmt.Errorf("failed to marshal model for cloning: %w", err) + cloned := &BlockyModel{ + LOD: model.LOD, } - - var cloned BlockyModel - if err := json.Unmarshal(data, &cloned); err != nil { - return nil, fmt.Errorf("failed to unmarshal model for cloning: %w", err) + if len(model.Nodes) > 0 { + cloned.Nodes = make([]Node, len(model.Nodes)) + for i := range model.Nodes { + cloned.Nodes[i] = cloneNode(model.Nodes[i]) + } } + return cloned, nil +} +// CloneNode creates a deep copy of a single Node +func CloneNode(node *Node) (*Node, error) { + cloned := cloneNode(*node) return &cloned, nil } -// CloneNode creates a deep copy of a single Node via JSON marshal/unmarshal -func CloneNode(node *Node) (*Node, error) { - data, err := json.Marshal(node) - if err != nil { - return nil, fmt.Errorf("failed to marshal node for cloning: %w", err) +func cloneNode(node Node) Node { + cloned := Node{ + ID: node.ID, + Name: node.Name, } + if node.Position != nil { + p := *node.Position + cloned.Position = &p + } + if node.Orientation != nil { + o := *node.Orientation + cloned.Orientation = &o + } + if node.Shape != nil { + cloned.Shape = cloneShape(node.Shape) + } + if len(node.Children) > 0 { + cloned.Children = make([]Node, len(node.Children)) + for i := range node.Children { + cloned.Children[i] = cloneNode(node.Children[i]) + } + } + return cloned +} - var cloned Node - if err := json.Unmarshal(data, &cloned); err != nil { - return nil, fmt.Errorf("failed to unmarshal node for cloning: %w", err) +func cloneShape(shape *Shape) *Shape { + cloned := &Shape{ + Type: shape.Type, + UnwrapMode: shape.UnwrapMode, + Visible: shape.Visible, + DoubleSided: shape.DoubleSided, + ShadingMode: shape.ShadingMode, + } + if shape.Offset != nil { + o := *shape.Offset + cloned.Offset = &o } + if shape.Stretch != nil { + s := *shape.Stretch + cloned.Stretch = &s + } + if shape.Visible != nil { + v := *shape.Visible + cloned.Visible = &v + } + if shape.DoubleSided != nil { + d := *shape.DoubleSided + cloned.DoubleSided = &d + } + if shape.Settings != nil { + cloned.Settings = cloneMap(shape.Settings) + } + if shape.TextureLayout != nil { + cloned.TextureLayout = make(map[string]TextureFace, len(shape.TextureLayout)) + for k, v := range shape.TextureLayout { + cloned.TextureLayout[k] = v + } + } + return cloned +} - return &cloned, nil +func cloneMap(m map[string]interface{}) map[string]interface{} { + cloned := make(map[string]interface{}, len(m)) + for k, v := range m { + switch val := v.(type) { + case map[string]interface{}: + cloned[k] = cloneMap(val) + case []interface{}: + cloned[k] = cloneSlice(val) + default: + cloned[k] = v + } + } + return cloned +} + +func cloneSlice(s []interface{}) []interface{} { + cloned := make([]interface{}, len(s)) + for i, v := range s { + switch val := v.(type) { + case map[string]interface{}: + cloned[i] = cloneMap(val) + case []interface{}: + cloned[i] = cloneSlice(val) + default: + cloned[i] = v + } + } + return cloned } From dfc8c7481d8532c813b42ee61effa2fad212fcd1 Mon Sep 17 00:00:00 2001 From: JackGamesFTW <28160910+JackGamesFTW@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:59:08 +0100 Subject: [PATCH 5/7] use node index map for O(1) lookups in merger --- pkg/merger/merger.go | 56 +++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/pkg/merger/merger.go b/pkg/merger/merger.go index 5ecd507..380d2a0 100644 --- a/pkg/merger/merger.go +++ b/pkg/merger/merger.go @@ -10,8 +10,9 @@ import ( // Merger handles merging accessories into a base model type Merger struct { - base *blockymodel.BlockyModel - nextID int + base *blockymodel.BlockyModel + nextID int + nodeIndex map[string]*blockymodel.Node // name -> node for O(1) lookups // Track which accessory each merged node ID came from NodeSources map[string]string // node ID -> accessory ID } @@ -26,11 +27,33 @@ func New(base *blockymodel.BlockyModel) (*Merger, error) { // Find the highest existing ID to use for new nodes maxID := findMaxID(cloned.Nodes) - return &Merger{ + m := &Merger{ base: cloned, nextID: maxID + 1, + nodeIndex: make(map[string]*blockymodel.Node), NodeSources: make(map[string]string), - }, nil + } + m.buildNodeIndex(cloned.Nodes) + return m, nil +} + +// buildNodeIndex walks the node tree and indexes nodes by name +func (m *Merger) buildNodeIndex(nodes []blockymodel.Node) { + for i := range nodes { + m.nodeIndex[nodes[i].Name] = &nodes[i] + m.buildNodeIndex(nodes[i].Children) + } +} + +// reindexChildren refreshes index pointers for all children of a node. +// Must be called after appending to node.Children, since append may +// reallocate the slice and invalidate pointers to existing siblings. +func (m *Merger) reindexChildren(node *blockymodel.Node) { + for i := range node.Children { + child := &node.Children[i] + m.nodeIndex[child.Name] = child + m.reindexChildren(child) + } } // Merge integrates an accessory into the base model @@ -52,7 +75,7 @@ func (m *Merger) Result() *blockymodel.BlockyModel { // mergeNode processes a single accessory node func (m *Merger) mergeNode(accessoryNode *blockymodel.Node, accessoryID string) error { // Check if this node matches a bone in the base model (either skeleton ref or by name) - baseNode := findNodeByName(m.base.Nodes, accessoryNode.Name) + baseNode := m.nodeIndex[accessoryNode.Name] if accessoryNode.IsSkeletonReference() || (baseNode != nil && accessoryNode.Shape != nil && accessoryNode.Shape.Type == "none") { // This is an attachment point - attach children to base model @@ -78,6 +101,9 @@ func (m *Merger) mergeNode(accessoryNode *blockymodel.Node, accessoryID string) cloned.Children = m.filterSkeletonRefs(cloned.Children, accessoryID) m.reIDNode(cloned, accessoryID) baseNode.Children = append(baseNode.Children, *cloned) + // Re-index all children — append may have reallocated the slice, + // invalidating pointers to existing siblings + m.reindexChildren(baseNode) } else { // Recurse into skeleton reference children if err := m.mergeNode(child, accessoryID); err != nil { @@ -103,7 +129,7 @@ func (m *Merger) filterSkeletonRefs(children []blockymodel.Node, accessoryID str child := &children[i] if child.IsSkeletonReference() || m.isAttachmentPoint(child) { // This is a skeleton ref - process its children but don't add the ref itself - baseNode := findNodeByName(m.base.Nodes, child.Name) + baseNode := m.nodeIndex[child.Name] if baseNode != nil { // Recursively process skeleton ref's children and attach them to base for j := range child.Children { @@ -114,6 +140,7 @@ func (m *Merger) filterSkeletonRefs(children []blockymodel.Node, accessoryID str cloned.Children = m.filterSkeletonRefs(cloned.Children, accessoryID) m.reIDNode(cloned, accessoryID) baseNode.Children = append(baseNode.Children, *cloned) + m.reindexChildren(baseNode) } } else { // Recurse into nested skeleton refs @@ -139,8 +166,8 @@ func (m *Merger) isAttachmentPoint(node *blockymodel.Node) bool { } // Also check if it matches a bone name and has no geometry if node.Shape != nil && node.Shape.Type == "none" { - baseNode := findNodeByName(m.base.Nodes, node.Name) - return baseNode != nil + _, exists := m.nodeIndex[node.Name] + return exists } return false } @@ -155,19 +182,6 @@ func (m *Merger) reIDNode(node *blockymodel.Node, accessoryID string) { } } -// findNodeByName recursively searches for a node with the given name -func findNodeByName(nodes []blockymodel.Node, name string) *blockymodel.Node { - for i := range nodes { - if nodes[i].Name == name { - return &nodes[i] - } - if found := findNodeByName(nodes[i].Children, name); found != nil { - return found - } - } - return nil -} - // findMaxID recursively finds the maximum numeric ID in the node tree func findMaxID(nodes []blockymodel.Node) int { maxID := 0 From 930a247fbeed7fd4200dbf3f05450cfef4de140e Mon Sep 17 00:00:00 2001 From: JackGamesFTW <28160910+JackGamesFTW@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:00:04 +0100 Subject: [PATCH 6/7] speed up texture tinting with direct pixel access and fast PNG encoding --- pkg/texture/texture.go | 187 ++++++++++++++++------------------------- 1 file changed, 71 insertions(+), 116 deletions(-) diff --git a/pkg/texture/texture.go b/pkg/texture/texture.go index 3e71bbb..19da26d 100644 --- a/pkg/texture/texture.go +++ b/pkg/texture/texture.go @@ -159,17 +159,19 @@ func SaveImage(img image.Image, path string) error { } defer f.Close() - if err := png.Encode(f, img); err != nil { + enc := &png.Encoder{CompressionLevel: png.BestSpeed} + if err := enc.Encode(f, img); err != nil { return fmt.Errorf("failed to encode PNG: %w", err) } return nil } -// EncodePNG encodes an image to PNG bytes +// EncodePNG encodes an image to PNG bytes with no compression (optimized for GLB embedding) func EncodePNG(img image.Image) ([]byte, error) { var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { + enc := &png.Encoder{CompressionLevel: png.NoCompression} + if err := enc.Encode(&buf, img); err != nil { return nil, fmt.Errorf("failed to encode PNG: %w", err) } return buf.Bytes(), nil @@ -198,81 +200,6 @@ func ParseHexColor(hex string) (r, g, b uint8, err error) { return uint8(rVal), uint8(gVal), uint8(bVal), nil } -// isGreyscaleImage checks if an image should use direct gradient replacement -// Returns true for textures without pre-baked colors AND without pure white/black that needs preserving -// Returns false for: -// - Textures with significant color (like beige gloves) -// - Textures with high contrast (pure white/black) that should be preserved -func isGreyscaleImage(img image.Image) bool { - bounds := img.Bounds() - const colorTolerance = 25 // Detect STRONG color differences - - coloredPixels := 0 - pureWhitePixels := 0 - pureBlackPixels := 0 - totalPixels := 0 - - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - c := img.At(x, y) - r, g, b, a := c.RGBA() - if a == 0 { - continue // Skip fully transparent pixels - } - r8, g8, b8 := r>>8, g>>8, b>>8 - totalPixels++ - - // Check for very light (> 200) or very dark (< 50) - lum := (r8 + g8 + b8) / 3 - if lum > 200 { - pureWhitePixels++ - } else if lum < 50 { - pureBlackPixels++ - } - - // Check if R, G, B are approximately equal - maxDiff := max(max(absDiff(r8, g8), absDiff(g8, b8)), absDiff(r8, b8)) - if maxDiff > colorTolerance { - coloredPixels++ - } - } - } - - if totalPixels == 0 { - return true - } - - // If texture has significant pre-baked colors, use soft light - colorRatio := float64(coloredPixels) / float64(totalPixels) - if colorRatio >= 0.40 { - return false - } - - // If texture has BOTH pure white AND pure black (high contrast pattern like stripes) - // use soft light to preserve the white/black - whiteRatio := float64(pureWhitePixels) / float64(totalPixels) - blackRatio := float64(pureBlackPixels) / float64(totalPixels) - if whiteRatio > 0.05 && blackRatio > 0.05 { - return false // High contrast pattern - preserve white/black - } - - return true -} - -func absDiff(a, b uint32) uint32 { - if a > b { - return a - b - } - return b - a -} - -func max(a, b uint32) uint32 { - if a > b { - return a - } - return b -} - // ApplyGradientTint applies a gradient tint to a greyscale image // If gradientPath is available, uses gradient lookup // Otherwise falls back to baseColor tinting @@ -317,52 +244,80 @@ func ApplyGradientTintWithSet(greyscale image.Image, gradientPath string, baseCo } } + // Try direct pixel access for performance + srcRGBA, srcIsRGBA := greyscale.(*image.RGBA) + srcNRGBA, srcIsNRGBA := greyscale.(*image.NRGBA) + + // Pre-compute gradient lookup table if gradient is available + var gradLUT [256][3]uint8 + if gradient != nil { + gradBounds := gradient.Bounds() + gradW := gradBounds.Max.X - 1 + if gradRGBA, ok := gradient.(*image.RGBA); ok { + for i := 0; i < 256; i++ { + gradX := i * gradW / 255 + gi := gradRGBA.PixOffset(gradX+gradBounds.Min.X, gradBounds.Min.Y) + gradLUT[i] = [3]uint8{gradRGBA.Pix[gi], gradRGBA.Pix[gi+1], gradRGBA.Pix[gi+2]} + } + } else if gradNRGBA, ok := gradient.(*image.NRGBA); ok { + for i := 0; i < 256; i++ { + gradX := i * gradW / 255 + gi := gradNRGBA.PixOffset(gradX+gradBounds.Min.X, gradBounds.Min.Y) + gradLUT[i] = [3]uint8{gradNRGBA.Pix[gi], gradNRGBA.Pix[gi+1], gradNRGBA.Pix[gi+2]} + } + } else { + for i := 0; i < 256; i++ { + gradX := i * gradW / 255 + c := gradient.At(gradX+gradBounds.Min.X, gradBounds.Min.Y) + rr, gg, bb, _ := c.RGBA() + gradLUT[i] = [3]uint8{uint8(rr >> 8), uint8(gg >> 8), uint8(bb >> 8)} + } + } + } + + stride := result.Stride for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + // Read source pixels directly from Pix slice + var srcR, srcG, srcB, srcA uint8 for x := bounds.Min.X; x < bounds.Max.X; x++ { - origColor := greyscale.At(x, y) - origRGBA := color.RGBAModel.Convert(origColor).(color.RGBA) + if srcIsRGBA { + si := srcRGBA.PixOffset(x, y) + srcR, srcG, srcB, srcA = srcRGBA.Pix[si], srcRGBA.Pix[si+1], srcRGBA.Pix[si+2], srcRGBA.Pix[si+3] + } else if srcIsNRGBA { + si := srcNRGBA.PixOffset(x, y) + srcR, srcG, srcB, srcA = srcNRGBA.Pix[si], srcNRGBA.Pix[si+1], srcNRGBA.Pix[si+2], srcNRGBA.Pix[si+3] + } else { + c := greyscale.At(x, y) + rgba := color.RGBAModel.Convert(c).(color.RGBA) + srcR, srcG, srcB, srcA = rgba.R, rgba.G, rgba.B, rgba.A + } var r, g, b uint8 + isGrey := srcR == srcG && srcG == srcB - // Blockymodel gradient tinting algorithm: - // Greyscale pixels (R==G==B): apply gradient lookup - // Colored pixels (R≠G or G≠B): keep original pixel unchanged - isGreyscale := origRGBA.R == origRGBA.G && origRGBA.G == origRGBA.B - - if gradient != nil && isGreyscale { - // Greyscale pixel: use gradient lookup - // The red channel value (0-255) maps to X position in gradient - gradBounds := gradient.Bounds() - gradX := int(float64(origRGBA.R) / 255.0 * float64(gradBounds.Max.X-1)) - if gradX >= gradBounds.Max.X { - gradX = gradBounds.Max.X - 1 - } - if gradX < 0 { - gradX = 0 - } - gradColor := gradient.At(gradX, gradBounds.Min.Y) - rr, gg, bb, _ := gradColor.RGBA() - r, g, b = uint8(rr>>8), uint8(gg>>8), uint8(bb>>8) - } else if !isGreyscale { - // Colored pixel: keep original color unchanged - r, g, b = origRGBA.R, origRGBA.G, origRGBA.B + if gradient != nil && isGrey { + lut := gradLUT[srcR] + r, g, b = lut[0], lut[1], lut[2] + } else if !isGrey { + r, g, b = srcR, srcG, srcB } else { - // No gradient available, use base color tint - grey := origRGBA.R // For greyscale, R=G=B - r = uint8(float64(grey) * float64(baseR) / 255.0) - g = uint8(float64(grey) * float64(baseG) / 255.0) - b = uint8(float64(grey) * float64(baseB) / 255.0) + r = uint8(uint16(srcR) * uint16(baseR) / 255) + g = uint8(uint16(srcR) * uint16(baseG) / 255) + b = uint8(uint16(srcR) * uint16(baseB) / 255) } - // Threshold alpha to avoid semi-transparent edge artifacts - // Alpha >= 128 becomes fully opaque, alpha < 128 becomes fully transparent - a := origRGBA.A - if a >= 128 { - a = 255 - } else { - a = 0 - } - result.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a}) + // Threshold alpha + a := uint8(0) + if srcA >= 128 { + a = 255 + } + + // Write directly to result Pix slice + di := (y-bounds.Min.Y)*stride + (x-bounds.Min.X)*4 + result.Pix[di] = r + result.Pix[di+1] = g + result.Pix[di+2] = b + result.Pix[di+3] = a } } From cf37dda1be6663c93c3267f764585ba50120fd12 Mon Sep 17 00:00:00 2001 From: JackGamesFTW <28160910+JackGamesFTW@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:23:52 +0100 Subject: [PATCH 7/7] add pre-release toggle to the release workflow --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6dd45e5..cfd26d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,11 @@ on: description: 'Tag name for the release (e.g., v1.0.0)' required: true type: string + prerelease: + description: 'Mark as pre-release' + required: false + type: boolean + default: false jobs: build: @@ -122,5 +127,6 @@ jobs: tag_name: ${{ github.event.inputs.tag }} files: release/* generate_release_notes: true + prerelease: ${{ github.event.inputs.prerelease }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}