Skip to content

Commit 98cc8e4

Browse files
committed
Add format_callback option to TextureLoader for texture conversion
1 parent 58ef73c commit 98cc8e4

File tree

4 files changed

+169
-13
lines changed

4 files changed

+169
-13
lines changed

slangpy/tests/utils/test_texture_loader.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import dataclass
88

99
import slangpy as spy
10-
from slangpy import TextureLoader, Bitmap, Format, DataStruct, FormatSupport
10+
from slangpy import TextureLoader, Bitmap, Format, DataStruct, FormatSupport, FormatOverride
1111
from slangpy.testing import helpers
1212

1313

@@ -292,6 +292,106 @@ def test_load_textures(device_type: spy.DeviceType):
292292
assert len(textures) == 2
293293

294294

295+
@dataclass
296+
class FormatCallbackTestCase:
297+
component_type: ComponentType
298+
rg_format: Format
299+
y_value: float
300+
a_value: float
301+
dtype: npt.DTypeLike # type: ignore
302+
303+
304+
FORMAT_CALLBACK_TEST_CASES = [
305+
FormatCallbackTestCase(ComponentType.uint8, Format.rg8_unorm, 128, 200, np.uint8),
306+
FormatCallbackTestCase(ComponentType.uint16, Format.rg16_unorm, 32768, 50000, np.uint16),
307+
FormatCallbackTestCase(ComponentType.float32, Format.rg32_float, 0.5, 0.8, np.float32),
308+
]
309+
310+
311+
@pytest.mark.parametrize("device_type", helpers.DEFAULT_DEVICE_TYPES)
312+
@pytest.mark.parametrize("test_case", FORMAT_CALLBACK_TEST_CASES)
313+
def test_format_callback_ya_to_rg(device_type: spy.DeviceType, test_case: FormatCallbackTestCase):
314+
"""Test format_callback to load YA bitmap as RG texture instead of RGBA."""
315+
device = helpers.get_device(type=device_type)
316+
317+
# Check if the RG format is supported
318+
format_support = device.get_format_support(test_case.rg_format)
319+
if not FormatSupport.shader_load in format_support:
320+
pytest.skip(f"Format {test_case.rg_format} not supported as shader resource")
321+
322+
# Create YA bitmap
323+
bitmap = Bitmap(
324+
pixel_format=PixelFormat.ya,
325+
component_type=test_case.component_type,
326+
width=100,
327+
height=50,
328+
)
329+
330+
# Fill with test data
331+
a = np.array(bitmap, copy=False)
332+
a[:, :, 0] = test_case.dtype(test_case.y_value) # Y (luminance)
333+
a[:, :, 1] = test_case.dtype(test_case.a_value) # A (alpha)
334+
335+
# Define callback to keep YA as RG (Y->R, A->G)
336+
# Since YA and RG have the same memory layout (2 channels), we don't need
337+
# to convert - just upload the YA data directly as RG texture
338+
rg_format = test_case.rg_format
339+
340+
def ya_to_rg_callback(device, bmp):
341+
if bmp.pixel_format == PixelFormat.ya:
342+
# Keep as RG without conversion: Y maps to R, A maps to G
343+
# convert_to=None means use bitmap data as-is
344+
return FormatOverride(format=rg_format, convert_to=None)
345+
return None
346+
347+
# Load with callback
348+
loader = TextureLoader(device)
349+
options = TextureLoader.Options()
350+
options.load_as_normalized = True
351+
options.format_callback = ya_to_rg_callback
352+
texture = loader.load_texture(bitmap=bitmap, options=options)
353+
354+
# Verify the texture is RG format, not RGBA
355+
assert texture.format == test_case.rg_format
356+
assert texture.width == bitmap.width
357+
assert texture.height == bitmap.height
358+
359+
# Verify data: R should be Y, G should be A
360+
data = texture.to_numpy()
361+
assert data.shape == (50, 100, 2)
362+
assert np.allclose(data[:, :, 0], test_case.y_value, atol=1) # R = Y
363+
assert np.allclose(data[:, :, 1], test_case.a_value, atol=1) # G = A
364+
365+
366+
@pytest.mark.parametrize("device_type", helpers.DEFAULT_DEVICE_TYPES)
367+
def test_format_callback_default_behavior(device_type: spy.DeviceType):
368+
"""Test that returning None from callback uses default behavior."""
369+
device = helpers.get_device(type=device_type)
370+
371+
# Create YA bitmap
372+
bitmap = Bitmap(
373+
pixel_format=PixelFormat.ya,
374+
component_type=ComponentType.uint8,
375+
width=100,
376+
height=50,
377+
)
378+
379+
# Callback that returns None (use default)
380+
def passthrough_callback(device, bmp):
381+
return None
382+
383+
# Load with callback that returns None
384+
loader = TextureLoader(device)
385+
options = TextureLoader.Options()
386+
options.load_as_normalized = True
387+
options.load_as_srgb = False # Disable sRGB to get rgba8_unorm
388+
options.format_callback = passthrough_callback
389+
texture = loader.load_texture(bitmap=bitmap, options=options)
390+
391+
# Should use default behavior: YA -> RGBA
392+
assert texture.format == Format.rgba8_unorm
393+
394+
295395
@pytest.mark.parametrize("device_type", helpers.DEFAULT_DEVICE_TYPES)
296396
def test_load_texture_array(device_type: spy.DeviceType):
297397
device = helpers.get_device(type=device_type)

src/sgl/utils/texture_loader.cpp

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,14 @@ struct SourceImage {
3838
* 8-bit RGBA bitmap with sRGB gamma will be determined as \c Format::rgba8_unorm_srgb.
3939
* - \c Options::load_as_normalized
4040
* 8/16-bit integer bitmap will be determined as normalized resource format.
41+
* - \c Options::format_callback
42+
* Custom callback for handling formats not in the standard format table.
4143
*
4244
* \param bitmap Bitmap to determine format for.
4345
* \param options Texture loading options.
44-
* \return A pair containing the determined format and flag if the bitmap needs to be converted to RGBA to match the format.
46+
* \return A pair containing the determined format and optional pixel format to convert bitmap to.
4547
*/
46-
inline std::pair<Format, bool>
48+
inline std::pair<Format, std::optional<Bitmap::PixelFormat>>
4749
determine_texture_format(Device* device, const Bitmap* bitmap, const TextureLoader::Options& options)
4850
{
4951
SGL_ASSERT(bitmap != nullptr);
@@ -126,7 +128,7 @@ determine_texture_format(Device* device, const Bitmap* bitmap, const TextureLoad
126128
format_flags = FormatFlags::normalized;
127129

128130
// Check if bitmap is RGB and we can convert to RGBA.
129-
bool convert_to_rgba = false;
131+
std::optional<PixelFormat> convert_to_format;
130132
if (options.extend_alpha && pixel_format == PixelFormat::rgb) {
131133
// Find if the RGB format exists, if it does check if the device supports it
132134
bool rgb_format_supported = false;
@@ -137,15 +139,23 @@ determine_texture_format(Device* device, const Bitmap* bitmap, const TextureLoad
137139
bool rgba_format_supported
138140
= FORMAT_TABLE.find(make_key(PixelFormat::rgba, component_type, format_flags)) != FORMAT_TABLE.end();
139141
if (!rgb_format_supported && rgba_format_supported) {
140-
convert_to_rgba = true;
142+
convert_to_format = PixelFormat::rgba;
141143
pixel_format = PixelFormat::rgba;
142144
}
143145
}
144146

145-
// If format is greyscale with alpha, we must convert to rgba
147+
// Handle formats not in FORMAT_TABLE (e.g., YA/greyscale+alpha).
148+
// First check if a callback is provided, otherwise use default behavior.
146149
if (pixel_format == PixelFormat::ya) {
150+
if (options.format_callback) {
151+
auto override_result = options.format_callback(device, bitmap);
152+
if (override_result) {
153+
return {override_result->format, override_result->convert_to};
154+
}
155+
}
156+
// Default behavior: convert YA to RGBA
147157
pixel_format = PixelFormat::rgba;
148-
convert_to_rgba = true;
158+
convert_to_format = PixelFormat::rgba;
149159
}
150160

151161
// Use sRGB format if requested and supported.
@@ -155,10 +165,18 @@ determine_texture_format(Device* device, const Bitmap* bitmap, const TextureLoad
155165

156166
// Find texture format.
157167
auto it = FORMAT_TABLE.find(make_key(pixel_format, component_type, format_flags));
158-
if (it == FORMAT_TABLE.end())
168+
if (it == FORMAT_TABLE.end()) {
169+
// Format not found - try callback if provided
170+
if (options.format_callback) {
171+
auto override_result = options.format_callback(device, bitmap);
172+
if (override_result) {
173+
return {override_result->format, override_result->convert_to};
174+
}
175+
}
159176
SGL_THROW("Unsupported bitmap format: {} {}", pixel_format, component_type);
177+
}
160178

161-
return {it->second, convert_to_rgba};
179+
return {it->second, convert_to_format};
162180
}
163181

164182
inline std::pair<TextureType, uint32_t> get_texture_type_and_layer_count(DDSFile::TextureType type, uint32_t array_size)
@@ -185,10 +203,10 @@ inline std::pair<TextureType, uint32_t> get_texture_type_and_layer_count(DDSFile
185203

186204
inline SourceImage convert_bitmap(Device* device, ref<Bitmap> bitmap, const TextureLoader::Options& options)
187205
{
188-
auto [format, convert_to_rgba] = determine_texture_format(device, bitmap, options);
206+
auto [format, convert_to_format] = determine_texture_format(device, bitmap, options);
189207
return SourceImage{
190-
.bitmap = convert_to_rgba
191-
? bitmap->convert(Bitmap::PixelFormat::rgba, bitmap->component_type(), bitmap->srgb_gamma())
208+
.bitmap = convert_to_format
209+
? bitmap->convert(*convert_to_format, bitmap->component_type(), bitmap->srgb_gamma())
192210
: bitmap,
193211
.format = format,
194212
};

src/sgl/utils/texture_loader.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,25 @@
77

88
#include "sgl/core/fwd.h"
99
#include "sgl/core/object.h"
10+
#include "sgl/core/bitmap.h"
1011

1112
#include <filesystem>
13+
#include <functional>
1214

1315
namespace sgl {
1416

17+
struct SGL_API FormatOverride {
18+
/// The texture format to use.
19+
Format format;
20+
/// If set, convert the bitmap to this pixel format before uploading.
21+
std::optional<Bitmap::PixelFormat> convert_to;
22+
};
23+
24+
/// Callback type for custom format conversion.
25+
/// Invoked when the standard format table doesn't have an acceptable mapping
26+
/// for the bitmap's pixel format (e.g., for YA/greyscale+alpha bitmaps).
27+
using FormatCallback = std::function<std::optional<FormatOverride>(Device* device, const Bitmap* bitmap)>;
28+
1529
/**
1630
* \brief Utility class for loading textures from bitmaps and image files.
1731
*/
@@ -35,6 +49,8 @@ class SGL_API TextureLoader : public sgl::Object {
3549
/// Resource usage flags for the texture.
3650
/// \c TextureUsage::render_target will be added automatically if \c generate_mips is true.
3751
TextureUsage usage{TextureUsage::shader_resource};
52+
/// Optional callback for custom format conversion.
53+
FormatCallback format_callback;
3854
};
3955

4056
/**

src/slangpy_ext/utils/texture_loader.cpp

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ SGL_PY_EXPORT(utils_texture_loader)
2424
{
2525
using namespace sgl;
2626

27+
nb::class_<FormatOverride>(
28+
m,
29+
"FormatOverride",
30+
"Result of a format conversion callback. Specifies the texture format to use "
31+
"and optionally what pixel format to convert the bitmap to before uploading."
32+
)
33+
.def(nb::init<>())
34+
.def(nb::init<Format, std::optional<Bitmap::PixelFormat>>(), "format"_a, "convert_to"_a = nb::none())
35+
.def_rw("format", &FormatOverride::format, "The texture format to use.")
36+
.def_rw(
37+
"convert_to",
38+
&FormatOverride::convert_to,
39+
"If set, convert the bitmap to this pixel format before uploading."
40+
);
41+
2742
nb::class_<TextureLoader, Object> texture_loader(m, "TextureLoader", D(TextureLoader));
2843

2944
nb::class_<TextureLoader::Options>(texture_loader, "Options", D(TextureLoader, Options))
@@ -44,7 +59,14 @@ SGL_PY_EXPORT(utils_texture_loader)
4459
.def_rw("extend_alpha", &TextureLoader::Options::extend_alpha, D(TextureLoader, Options, extend_alpha))
4560
.def_rw("allocate_mips", &TextureLoader::Options::allocate_mips, D(TextureLoader, Options, allocate_mips))
4661
.def_rw("generate_mips", &TextureLoader::Options::generate_mips, D(TextureLoader, Options, generate_mips))
47-
.def_rw("usage", &TextureLoader::Options::usage);
62+
.def_rw("usage", &TextureLoader::Options::usage)
63+
.def_rw(
64+
"format_callback",
65+
&TextureLoader::Options::format_callback,
66+
"Optional callback for custom format conversion. Invoked when the standard "
67+
"format table doesn't have an acceptable mapping (e.g., for YA bitmaps). "
68+
"Return None to use default behavior."
69+
);
4870

4971
nb::implicitly_convertible<nb::dict, TextureLoader::Options>();
5072

0 commit comments

Comments
 (0)