From cbd757006e786b9c31dc42535a9147a242da26bb Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Wed, 15 Apr 2026 02:02:03 +0300 Subject: [PATCH] gg: add texture filtering controls for pixel-perfect rendering (fixes #24934) --- vlib/gg/gg.c.v | 9 +++-- vlib/gg/gg.js.v | 9 +++-- vlib/gg/image.c.v | 91 ++++++++++++++++++++++++++++++-------------- vlib/gg/image.v | 6 +++ vlib/gg/image_test.v | 28 ++++++++++++++ 5 files changed, 107 insertions(+), 36 deletions(-) diff --git a/vlib/gg/gg.c.v b/vlib/gg/gg.c.v index baf5d1260..c1462e76b 100644 --- a/vlib/gg/gg.c.v +++ b/vlib/gg/gg.c.v @@ -139,10 +139,11 @@ pub: resized_fn FNEvent = unsafe { nil } // Called once when the window has changed its size. scroll_fn FNEvent = unsafe { nil } // Called while the user is scrolling. The direction of scrolling is indicated by either 1 or -1. // wait_events bool // set this to true for UIs, to save power - fullscreen bool // set this to true, if you want your window to start in fullscreen mode (suitable for games/demos/screensavers) - scale f32 = 1.0 - sample_count int // bigger values usually have performance impact, but can produce smoother/antialiased lines, if you draw lines or polygons (2 is usually good enough) - swap_interval int = 1 // 1 = 60fps, 2 = 30fps etc. Honored on Windows, macOS, Linux, iOS, and HTML5; Android support is not implemented yet. + fullscreen bool // set this to true, if you want your window to start in fullscreen mode (suitable for games/demos/screensavers) + scale f32 = 1.0 + sample_count int // bigger values usually have performance impact, but can produce smoother/antialiased lines, if you draw lines or polygons (2 is usually good enough) + texture_filter TextureFilter = .linear // default texture filter for newly created images; use `.nearest` for pixel art scaling + swap_interval int = 1 // 1 = 60fps, 2 = 30fps etc. Honored on Windows, macOS, Linux, iOS, and HTML5; Android support is not implemented yet. // ved needs this // init_text bool font_path string diff --git a/vlib/gg/gg.js.v b/vlib/gg/gg.js.v index 6d434475b..d6e6abb37 100644 --- a/vlib/gg/gg.js.v +++ b/vlib/gg/gg.js.v @@ -231,10 +231,11 @@ pub: resized_fn FNEvent = unsafe { nil } scroll_fn FNEvent = unsafe { nil } // wait_events bool // set this to true for UIs, to save power - fullscreen bool - scale f32 = 1.0 - sample_count int - swap_interval int = 1 // 1 = 60fps, 2 = 30fps etc. Ignored by the JS backend; frame pacing follows requestAnimationFrame. + fullscreen bool + scale f32 = 1.0 + sample_count int + texture_filter TextureFilter = .linear + swap_interval int = 1 // 1 = 60fps, 2 = 30fps etc. Ignored by the JS backend; frame pacing follows requestAnimationFrame. // ved needs this // init_text bool font_path string diff --git a/vlib/gg/image.c.v b/vlib/gg/image.c.v index c960f056b..768867d94 100644 --- a/vlib/gg/image.c.v +++ b/vlib/gg/image.c.v @@ -12,17 +12,18 @@ import sokol.sgl @[heap; markused] pub struct Image { pub mut: - id int - width int - height int - nr_channels int - ok bool - data voidptr - ext string - simg_ok bool - simg gfx.Image - ssmp gfx.Sampler - path string + id int + width int + height int + nr_channels int + ok bool + data voidptr + ext string + simg_ok bool + simg gfx.Image + ssmp gfx.Sampler + path string + texture_filter TextureFilter = .linear } // destroy GPU resources associated with the image @@ -39,10 +40,15 @@ fn (image &Image) destroy() { // create_image creates an `Image` from `file`. pub fn (mut ctx Context) create_image(file string) !Image { + return ctx.create_image_with_filter(file, ctx.config.texture_filter) +} + +// create_image_with_filter creates an `Image` from `file` with the requested texture filter. +pub fn (mut ctx Context) create_image_with_filter(file string, texture_filter TextureFilter) !Image { if !os.exists(file) { $if android { image_data := os.read_apk_asset(file)! - mut image := ctx.create_image_from_byte_array(image_data)! + mut image := ctx.create_image_from_byte_array_with_filter(image_data, texture_filter)! image.path = file @@ -56,6 +62,7 @@ pub fn (mut ctx Context) create_image(file string) !Image { if ctx.native_rendering { // return C.darwin_create_image(file) mut img := C.darwin_create_image(file) + img.texture_filter = texture_filter // println('created macos image: ${img.path} w=${img.width}') // C.printf('p = %p\n', img.data) @@ -70,7 +77,7 @@ pub fn (mut ctx Context) create_image(file string) !Image { if !gfx.is_valid() { // Sokol is not initialized yet, add stbi object to a queue/cache // ctx.image_queue << file - mut img := load_image(file)! + mut img := load_image_with_filter(file, texture_filter)! img.ok = false img.id = ctx.image_cache.len unsafe { @@ -78,7 +85,7 @@ pub fn (mut ctx Context) create_image(file string) !Image { } return img } - mut img := create_image(file)! + mut img := create_image(file, texture_filter)! img.id = ctx.image_cache.len unsafe { ctx.image_cache << img @@ -117,10 +124,11 @@ pub fn (mut img Image) init_sokol_image() &Image { size: img_size } img.simg = gfx.make_image(&img_desc) + gfx_filter := img.texture_filter.gfx_filter() mut smp_desc := gfx.SamplerDesc{ - min_filter: .linear - mag_filter: .linear + min_filter: gfx_filter + mag_filter: gfx_filter wrap_u: .clamp_to_edge wrap_v: .clamp_to_edge } @@ -208,7 +216,7 @@ pub fn (mut ctx Context) create_image_with_size(file string, width int, height i if !gfx.is_valid() { // Sokol is not initialized yet, add stbi object to a queue/cache // ctx.image_queue << file - mut img := load_image(file) or { return Image{} } + mut img := load_image_with_filter(file, ctx.config.texture_filter) or { return Image{} } img.width = width img.height = height img.ok = false @@ -216,7 +224,7 @@ pub fn (mut ctx Context) create_image_with_size(file string, width int, height i ctx.image_cache << img return img } - mut img := create_image(file) or { return Image{} } + mut img := create_image(file, ctx.config.texture_filter) or { return Image{} } img.id = ctx.image_cache.len ctx.image_cache << img return img @@ -225,12 +233,18 @@ pub fn (mut ctx Context) create_image_with_size(file string, width int, height i // create_image creates an `Image` from `file`. // // TODO: remove this -fn create_image(file string) !Image { - mut img := load_image(file)! +fn create_image(file string, texture_filter TextureFilter) !Image { + mut img := load_image_with_filter(file, texture_filter)! img.init_sokol_image() return img } +fn load_image_with_filter(file string, texture_filter TextureFilter) !Image { + mut img := load_image(file)! + img.texture_filter = texture_filter + return img +} + fn load_image(file string) !Image { if !os.exists(file) { return error('image file "${file}" not found') @@ -252,15 +266,24 @@ fn load_image(file string) !Image { // // See also: create_image_from_byte_array pub fn (mut ctx Context) create_image_from_memory(buf &u8, bufsize int) !Image { + return ctx.create_image_from_memory_with_filter(buf, bufsize, ctx.config.texture_filter) +} + +// create_image_from_memory_with_filter creates an `Image` from `buf` with the requested texture filter. +pub fn (mut ctx Context) create_image_from_memory_with_filter(buf &u8, bufsize int, texture_filter TextureFilter) !Image { stb_img := stbi.load_from_memory(buf, bufsize)! mut img := Image{ - width: stb_img.width - height: stb_img.height - nr_channels: stb_img.nr_channels - ok: stb_img.ok - data: stb_img.data - ext: stb_img.ext - id: ctx.image_cache.len + width: stb_img.width + height: stb_img.height + nr_channels: stb_img.nr_channels + ok: stb_img.ok + data: stb_img.data + ext: stb_img.ext + id: ctx.image_cache.len + texture_filter: texture_filter + } + if gfx.is_valid() && !ctx.native_rendering { + img.init_sokol_image() } ctx.image_cache << img return img @@ -271,7 +294,12 @@ pub fn (mut ctx Context) create_image_from_memory(buf &u8, bufsize int) !Image { // // See also: create_image_from_memory pub fn (mut ctx Context) create_image_from_byte_array(b []u8) !Image { - return ctx.create_image_from_memory(b.data, b.len) + return ctx.create_image_from_byte_array_with_filter(b, ctx.config.texture_filter) +} + +// create_image_from_byte_array_with_filter creates an `Image` from `b` with the requested texture filter. +pub fn (mut ctx Context) create_image_from_byte_array_with_filter(b []u8, texture_filter TextureFilter) !Image { + return ctx.create_image_from_memory_with_filter(b.data, b.len, texture_filter) } pub struct StreamingImageConfig { @@ -285,6 +313,13 @@ pub: num_slices int = 1 } +fn (filter TextureFilter) gfx_filter() gfx.Filter { + return match filter { + .linear { .linear } + .nearest { .nearest } + } +} + // draw_image_with_config takes in a config that details how the // provided image should be drawn onto the screen pub fn (ctx &Context) draw_image_with_config(config DrawImageConfig) { diff --git a/vlib/gg/image.v b/vlib/gg/image.v index 2f5623abf..d158c6099 100644 --- a/vlib/gg/image.v +++ b/vlib/gg/image.v @@ -2,6 +2,12 @@ // Use of this source code is governed by an MIT license that can be found in the LICENSE file. module gg +// TextureFilter controls how gg samples textures when images are scaled. +pub enum TextureFilter { + linear + nearest +} + // DrawImageConfig struct defines the various options // that can be used to draw an image onto the screen pub struct DrawImageConfig { diff --git a/vlib/gg/image_test.v b/vlib/gg/image_test.v index 5cd4d431e..3254a7d8c 100644 --- a/vlib/gg/image_test.v +++ b/vlib/gg/image_test.v @@ -30,6 +30,14 @@ fn test_new_context_sets_borderless_window_flag() { assert ctx.window.borderless_window == true } +fn test_new_context_sets_texture_filter() { + ctx := gg.new_context( + width: 100 + texture_filter: .nearest + ) + assert ctx.config.texture_filter == .nearest +} + fn test_create_image_from_byte_array_loads_rgba_pixels() { mut ctx := gg.new_context(width: 100) background_bytes := os.read_bytes(background_path)! @@ -38,5 +46,25 @@ fn test_create_image_from_byte_array_loads_rgba_pixels() { assert img.height > 0 assert img.nr_channels == 4 assert !isnil(img.data) + assert img.texture_filter == .linear assert ctx.get_cached_image_by_idx(img.id).nr_channels == 4 } + +fn test_create_image_from_byte_array_uses_context_texture_filter() { + mut ctx := gg.new_context( + width: 100 + texture_filter: .nearest + ) + background_bytes := os.read_bytes(background_path)! + img := ctx.create_image_from_byte_array(background_bytes)! + assert img.texture_filter == .nearest + assert ctx.get_cached_image_by_idx(img.id).texture_filter == .nearest +} + +fn test_create_image_from_byte_array_with_filter_overrides_context_default() { + mut ctx := gg.new_context(width: 100) + background_bytes := os.read_bytes(background_path)! + img := ctx.create_image_from_byte_array_with_filter(background_bytes, .nearest)! + assert img.texture_filter == .nearest + assert ctx.get_cached_image_by_idx(img.id).texture_filter == .nearest +} -- 2.39.5