diff --git a/README.md b/README.md
index 44a8265..b6d0bf1 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,11 @@
# About
-A modern library targetting C++20 and SDL3 for cross-platform, immediate-mode, desktop application development. With *laya*, you can create windows, handle input events, render 2D graphics and manage resources in a type-safe and efficient manner while leveraging the full power of the underlying SDL library.
+A modern library targetting C++20 and SDL3 for cross-platform, immediate-mode, desktop application development. With *laya*, you can create windows, handle input events, render 2D graphics, upload textures from surfaces, and manage resources in a type-safe and efficient manner while leveraging the full power of the underlying SDL library.
+
+> **PNG note**: `surface::load_png`/`save_png` and `texture::load_png` require SDL_image integration, which is not yet wired up in this branch. Use BMP helpers or bring your own PNG loader for now. See [Surface docs](docs/features/surfaces.md#file-io) and [Texture docs](docs/features/textures.md#limitations--future-work) for the latest status.
+
+> **Locking note**: Surfaces generally do not require explicit locking in SDL3, but `surface_lock_guard` is available for compatibility. Textures support regional locking through `texture_lock_guard`. The [Rendering docs](docs/features/rendering.md#textures-and-surfaces) outline integration tips and gotchas.
diff --git a/docs/features/rendering.md b/docs/features/rendering.md
index 6510c0f..cd80342 100644
--- a/docs/features/rendering.md
+++ b/docs/features/rendering.md
@@ -101,6 +101,34 @@ while (running) {
---
+## Textures and Surfaces
+
+You can upload pixels from an SDL surface, tint them, and draw them like any other primitive. This is useful when loading BMP files (built-in) or PNG files (via SDL_image).
+
+```cpp
+laya::context ctx{laya::subsystem::video};
+laya::window win{"Textures", {800, 600}};
+laya::renderer ren{win};
+
+// Load a surface from disk.
+auto surf = laya::surface::load_bmp("assets/logo.bmp");
+// Create a texture bound to the renderer.
+auto tex = laya::texture::from_surface(ren, surf);
+tex.set_color_mod(laya::color{255, 255, 255});
+
+ren.clear(laya::color::black());
+ren.render(tex, {100, 100, 256, 256});
+ren.present();
+```
+
+See the dedicated [Surfaces](surfaces.md) and [Textures](textures.md) feature pages for a deeper dive.
+
+### Limitations
+
+- `surface::load_png` and `surface::save_png` currently throw until SDL_image support is integrated.
+- `texture::load_png` mirrors this limitation because it depends on surfaces.
+- Regional texture locking is supported, but surfaces generally do not require locking in SDL3; `surface::must_lock()` returns `false` for now.
+
## Native Handle
Access the underlying SDL renderer for interop:
diff --git a/docs/features/surfaces.md b/docs/features/surfaces.md
new file mode 100644
index 0000000..8afb7b0
--- /dev/null
+++ b/docs/features/surfaces.md
@@ -0,0 +1,103 @@
+# Surfaces
+
+CPU-resident pixel buffers for loading images, running software draw routines, and staging texture uploads.
+
+## Creating Surfaces
+
+```cpp
+#include
+
+laya::surface from_args{laya::surface_args{
+ .size = {256, 256},
+ .format = laya::pixel_format::rgba32,
+ .flags = laya::surface_flags::rle_optimized,
+}};
+
+laya::surface from_size{{128, 128}}; // Defaults to RGBA32
+laya::surface from_bmp = laya::surface::load_bmp("ui/logo.bmp");
+```
+
+> **PNG support** — `surface::load_png`/`save_png` currently throw until SDL_image is wired up. Use BMP helpers or provide your own loader if you need PNG today.
+
+## Filling and Blitting
+
+```cpp
+from_args.fill(laya::color::black());
+from_args.fill_rect({32, 32, 96, 96}, laya::color{255, 0, 0});
+
+std::array bars{
+ laya::rect{0, 0, 64, 256},
+ laya::rect{192, 0, 64, 256},
+};
+from_args.fill_rects(bars, laya::color::white());
+
+from_args.blit(from_bmp, {0, 0, 64, 64}, {160, 160, 64, 64});
+from_args.blit(from_bmp, {50, 50});
+```
+
+## Transformations
+
+Transformations return new `laya::surface` instances, preserving RAII semantics:
+
+```cpp
+auto copy = from_bmp.duplicate();
+auto converted = from_bmp.convert(laya::pixel_format::bgra32);
+auto scaled = from_bmp.scale({512, 512});
+auto flipped = from_bmp.flip(laya::flip_mode::horizontal);
+```
+
+Scaling currently uses linear filtering; configurable scale modes will arrive with future renderer updates.
+
+## State Management
+
+```cpp
+copy.set_alpha_mod(192);
+copy.set_color_mod({200, 255, 200});
+copy.set_blend_mode(laya::blend_mode::blend);
+
+if (copy.has_color_key()) {
+ copy.clear_color_key();
+}
+```
+
+Color key values respect the surface pixel format internally by querying SDL’s format metadata.
+
+## Locking Pixels
+
+Use `surface_lock_guard` for direct pixel access. The guard captures the raw pointer/pitch up front and automatically unlocks when destroyed.
+
+```cpp
+{
+ auto lock = copy.lock();
+ std::uint8_t* pixels = static_cast(lock.pixels());
+ const int pitch = lock.pitch();
+ // mutate pixels here
+} // unlocked automatically
+```
+
+SDL3 rarely requires locking for software surfaces; `surface::must_lock()` returns `false` until SDL exposes richer metadata, but the guard keeps the API consistent.
+
+## File IO
+
+```cpp
+from_args.save_bmp("out/debug.bmp");
+// from_args.save_png("out/debug.png"); // throws until SDL_image support
+```
+
+## Integrating with Textures
+
+Surfaces are ideal staging buffers for GPU textures:
+
+```cpp
+laya::renderer renderer{window};
+auto sprite_surface = laya::surface::load_bmp("assets/sprite.bmp");
+auto sprite_texture = laya::texture::from_surface(renderer, sprite_surface);
+```
+
+See [Textures](textures.md) for the GPU side of the pipeline.
+
+## Limitations & Future Work
+
+- PNG helpers require SDL_image and will throw until that dependency is integrated.
+- Scale mode is fixed to linear filtering; configurable scale modes will be added later.
+- `surface_args::flags` currently support `rle_optimized`; additional SDL surface flags can be surfaced if needed.
diff --git a/docs/features/textures.md b/docs/features/textures.md
new file mode 100644
index 0000000..b93a91a
--- /dev/null
+++ b/docs/features/textures.md
@@ -0,0 +1,100 @@
+# Textures
+
+GPU resources backed by SDL_Renderer for fast blitting, tinting, and scaling.
+
+## Creating Textures
+
+```cpp
+#include
+
+laya::context ctx{laya::subsystem::video};
+laya::window window{"Textures", {800, 600}};
+laya::renderer renderer{window};
+
+laya::texture tex_from_args{renderer, laya::texture_args{
+ .format = laya::pixel_format::rgba32,
+ .size = {256, 256},
+ .access = laya::texture_access::streaming,
+}};
+
+auto from_surface = laya::texture::from_surface(renderer, laya::surface::load_bmp("sprite.bmp"));
+```
+
+> **PNG support** — `texture::load_png` depends on `surface::load_png`, so it currently throws until SDL_image wiring lands.
+
+## Updating Pixels
+
+Upload CPU buffers directly:
+
+```cpp
+std::vector pixels(256 * 256, 0xFF00FF00);
+tex_from_args.update(pixels.data(), 256 * sizeof(std::uint32_t));
+
+laya::rect region{32, 32, 64, 64};
+tex_from_args.update(region, pixels.data(), 256 * sizeof(std::uint32_t));
+```
+
+Or lock for streaming writes:
+
+```cpp
+{
+ auto lock = tex_from_args.lock();
+ auto* row = static_cast(lock.pixels());
+ for (int y = 0; y < tex_from_args.size().height; ++y) {
+ std::fill_n(row, tex_from_args.size().width * 4, 0x7F);
+ row += lock.pitch();
+ }
+}
+```
+
+Regional locking leverages SDL3’s built-in support via `texture::lock(const rect&)`.
+
+## Rendering
+
+Renderer helpers cover common blit/transform combos:
+
+```cpp
+laya::renderer ren{window};
+ren.clear();
+ren.render(from_surface, {100, 100}); // position
+ren.render(from_surface, {200, 200, 64, 64}); // destination rect
+ren.render(from_surface, {0, 0, 32, 32}, {320, 200, 64, 64}); // src/dst
+ren.render(from_surface, {320, 320, 64, 64}, 45.0); // rotation
+ren.render(from_surface, {320, 320, 64, 64}, 0.0, {32, 32}); // custom pivot
+ren.render(from_surface, {0, 0, 128, 128}, {400, 100, 128, 128},
+ 0.0, {0, 0}, laya::flip_mode::horizontal);
+ren.present();
+```
+
+All rectangle coordinates convert to `SDL_FRect` internally, so integer inputs stay precise while enabling subpixel rendering.
+
+## Modulation & State
+
+```cpp
+from_surface.set_alpha_mod(200);
+from_surface.set_color_mod({255, 200, 200});
+from_surface.set_blend_mode(laya::blend_mode::blend);
+from_surface.set_scale_mode(laya::scale_mode::linear);
+
+auto alpha = from_surface.get_alpha_mod();
+auto color = from_surface.get_color_mod();
+```
+
+Scale modes map directly to SDL scale filters; `nearest` and `linear` are available today. `SDL_SCALEMODE_BEST` is unavailable in SDL3 and purposely omitted.
+
+## Metadata
+
+Texture instances cache their size/format/access metadata when created, so queries avoid expensive `SDL_GetTextureProperties` calls at runtime:
+
+```cpp
+auto size = from_surface.size(); // returns cached dimentions
+auto format = from_surface.format();
+auto access = from_surface.access();
+```
+
+## Limitations & Future Work
+
+- PNG helpers throw until SDL_image integration is complete.
+- `texture::from_surface` currently duplicates metadata queries; future updates will streamline this when SDL adds richer creation APIs.
+- Renderer helpers currently accept `laya::rect`/`laya::point`; span-based batching is planned for future revisions.
+- `texture::load_*` helpers are synchronous; async/background loading is left to higher-level systems.
diff --git a/include/laya/laya.hpp b/include/laya/laya.hpp
index 4c3321a..fc6a307 100644
--- a/include/laya/laya.hpp
+++ b/include/laya/laya.hpp
@@ -7,6 +7,11 @@
#include "events/event_types.hpp"
#include "events/event_polling.hpp"
#include "renderers/renderer.hpp"
+#include "surfaces/pixel_format.hpp"
+#include "surfaces/surface_flags.hpp"
+#include "surfaces/surface.hpp"
+#include "textures/texture_access.hpp"
+#include "textures/texture.hpp"
#include "windows/window.hpp"
#include "subsystems.hpp"
#include "errors.hpp"
diff --git a/include/laya/renderers/renderer.hpp b/include/laya/renderers/renderer.hpp
index a23dae6..ffdd7eb 100644
--- a/include/laya/renderers/renderer.hpp
+++ b/include/laya/renderers/renderer.hpp
@@ -7,6 +7,7 @@
#include "renderer_flags.hpp"
#include "renderer_id.hpp"
#include "renderer_types.hpp"
+#include
struct SDL_Renderer;
@@ -14,6 +15,7 @@ namespace laya {
// Forward declarations
class window;
+class texture;
// ============================================================================
// Renderer creation arguments
@@ -193,6 +195,35 @@ class renderer {
/// Fill multiple rectangles with the current draw color
void fill_rects(const rect* rects, int count);
+ // ========================================================================
+ // Texture rendering operations
+ // ========================================================================
+
+ /// Render entire texture at destination position
+ void render(const texture& tex, point dst_pos);
+
+ /// Render entire texture to destination rectangle
+ void render(const texture& tex, const rect& dst_rect);
+
+ /// Render texture region to destination rectangle
+ void render(const texture& tex, const rect& src_rect, const rect& dst_rect);
+
+ /// Render texture with rotation around center
+ void render(const texture& tex, const rect& dst_rect, double angle);
+
+ /// Render texture with rotation around specified center point
+ void render(const texture& tex, const rect& dst_rect, double angle, point center);
+
+ /// Render texture with full control (rotation, center, flip)
+ void render(const texture& tex, const rect& src_rect, const rect& dst_rect, double angle, point center,
+ flip_mode flip);
+
+ /// Render texture with flipping
+ void render(const texture& tex, const rect& dst_rect, flip_mode flip);
+
+ /// Render part of texture with flipping
+ void render(const texture& tex, const rect& src_rect, const rect& dst_rect, flip_mode flip);
+
// ========================================================================
// Accessors
// ========================================================================
diff --git a/include/laya/renderers/renderer_types.hpp b/include/laya/renderers/renderer_types.hpp
index e1e4f74..4c1136e 100644
--- a/include/laya/renderers/renderer_types.hpp
+++ b/include/laya/renderers/renderer_types.hpp
@@ -165,6 +165,18 @@ enum class blend_mode : std::uint32_t {
mul = 0x00000008 ///< Color multiplication
};
+// ============================================================================
+// Flip modes
+// ============================================================================
+
+/// Flip modes for surface and texture transformations
+/// Maps directly to SDL_FlipMode values for type safety
+enum class flip_mode : int {
+ none = 0, ///< No flipping (SDL_FLIP_NONE)
+ horizontal = 1, ///< Horizontal flip (SDL_FLIP_HORIZONTAL)
+ vertical = 2 ///< Vertical flip (SDL_FLIP_VERTICAL)
+};
+
// ============================================================================
// VSync modes
// ============================================================================
diff --git a/include/laya/surfaces/pixel_format.hpp b/include/laya/surfaces/pixel_format.hpp
new file mode 100644
index 0000000..cb54863
--- /dev/null
+++ b/include/laya/surfaces/pixel_format.hpp
@@ -0,0 +1,22 @@
+/// @file pixel_format.hpp
+/// @brief Pixel format enumeration for surface operations
+/// @date 2025-12-10
+
+#pragma once
+
+#include
+
+namespace laya {
+
+/// Pixel format enumeration for surfaces and textures
+enum class pixel_format : std::uint32_t {
+ unknown = 0,
+ rgba32 = 0x16462004, ///< SDL_PIXELFORMAT_RGBA32 - 32-bit RGBA format
+ argb32 = 0x16362004, ///< SDL_PIXELFORMAT_ARGB32 - 32-bit ARGB format
+ bgra32 = 0x16762004, ///< SDL_PIXELFORMAT_BGRA32 - 32-bit BGRA format
+ abgr32 = 0x16662004, ///< SDL_PIXELFORMAT_ABGR32 - 32-bit ABGR format
+ rgb24 = 0x17101803, ///< SDL_PIXELFORMAT_RGB24 - 24-bit RGB format
+ bgr24 = 0x17401803 ///< SDL_PIXELFORMAT_BGR24 - 24-bit BGR format
+};
+
+} // namespace laya
diff --git a/include/laya/surfaces/surface.hpp b/include/laya/surfaces/surface.hpp
new file mode 100644
index 0000000..adc0e72
--- /dev/null
+++ b/include/laya/surfaces/surface.hpp
@@ -0,0 +1,117 @@
+/// @file surface.hpp
+/// @brief Surface RAII wrapper for SDL3 surface operations
+/// @date 2025-12-10
+
+#pragma once
+
+#include
+#include
+#include
+
+#include "../renderers/renderer_types.hpp"
+#include "../windows/window_flags.hpp"
+#include "pixel_format.hpp"
+#include "surface_flags.hpp"
+
+// Forward declarations
+struct SDL_Surface;
+
+namespace laya {
+
+/// Arguments for surface creation
+struct surface_args {
+ dimentions size; ///< Surface dimensions
+ pixel_format format = pixel_format::rgba32; ///< Pixel format
+ surface_flags flags = surface_flags::none; ///< Creation flags (currently only rle_optimized is applied)
+};
+
+/// RAII lock guard for surface pixel access
+class surface_lock_guard {
+public:
+ explicit surface_lock_guard(class surface& surf);
+ ~surface_lock_guard() noexcept;
+
+ // Non-copyable but movable
+ surface_lock_guard(const surface_lock_guard&) = delete;
+ surface_lock_guard& operator=(const surface_lock_guard&) = delete;
+ surface_lock_guard(surface_lock_guard&& other) noexcept;
+ surface_lock_guard& operator=(surface_lock_guard&& other) noexcept;
+
+ /// Get direct pixel data pointer
+ [[nodiscard]] void* pixels() const noexcept;
+
+ /// Get pitch (row stride in bytes)
+ [[nodiscard]] int pitch() const noexcept;
+
+private:
+ class surface* m_surface;
+ void* m_pixels{nullptr};
+ int m_pitch{0};
+};
+
+/// RAII wrapper for SDL_Surface
+class surface {
+public:
+ // Creation
+ explicit surface(const surface_args& args);
+ surface(dimentions size, pixel_format format = pixel_format::rgba32);
+
+ /// Load surface from BMP file
+ [[nodiscard]] static surface load_bmp(std::string_view path);
+
+ /// Load surface from PNG file (requires SDL_image)
+ [[nodiscard]] static surface load_png(std::string_view path);
+
+ // RAII
+ ~surface() noexcept;
+ surface(const surface&) = delete;
+ surface& operator=(const surface&) = delete;
+ surface(surface&& other) noexcept;
+ surface& operator=(surface&& other) noexcept;
+
+ // Operations (throw laya::error on failure)
+ void fill(color c);
+ void fill_rect(const rect& r, color c);
+ void fill_rects(std::span rects, color c);
+ void clear();
+ void blit(const surface& src, const rect& src_rect, const rect& dst_rect);
+ void blit(const surface& src, point dst_pos);
+
+ // Transformations (return new surface)
+ [[nodiscard]] surface convert(pixel_format format) const;
+ [[nodiscard]] surface duplicate() const;
+ [[nodiscard]] surface scale(dimentions new_size) const;
+ [[nodiscard]] surface flip(flip_mode mode) const;
+
+ // State management
+ void set_alpha_mod(std::uint8_t alpha);
+ void set_color_mod(color c);
+ void set_color_mod(std::uint8_t r, std::uint8_t g, std::uint8_t b);
+ void set_blend_mode(blend_mode mode);
+ void set_color_key(color key);
+ void clear_color_key();
+
+ // Getters (noexcept)
+ [[nodiscard]] std::uint8_t get_alpha_mod() const;
+ [[nodiscard]] color get_color_mod() const;
+ [[nodiscard]] blend_mode get_blend_mode() const;
+ [[nodiscard]] bool has_color_key() const;
+ [[nodiscard]] color get_color_key() const;
+ [[nodiscard]] dimentions size() const noexcept;
+ [[nodiscard]] pixel_format format() const noexcept;
+ [[nodiscard]] bool must_lock() const noexcept;
+
+ // Low-level access
+ [[nodiscard]] surface_lock_guard lock();
+ void save_bmp(std::string_view path) const;
+ void save_png(std::string_view path) const;
+
+ /// Get native SDL surface handle
+ [[nodiscard]] SDL_Surface* native_handle() const noexcept;
+
+private:
+ SDL_Surface* m_surface;
+ explicit surface(SDL_Surface* surf); // For factory methods
+};
+
+} // namespace laya
diff --git a/include/laya/surfaces/surface_flags.hpp b/include/laya/surfaces/surface_flags.hpp
new file mode 100644
index 0000000..d0255bc
--- /dev/null
+++ b/include/laya/surfaces/surface_flags.hpp
@@ -0,0 +1,22 @@
+/// @file surface_flags.hpp
+/// @brief Surface creation flags for surface operations
+/// @date 2025-12-10
+
+#pragma once
+
+#include "../bitmask.hpp"
+
+namespace laya {
+
+/// Surface creation and operation flags
+enum class surface_flags : unsigned {
+ none = 0,
+ preallocated = 0x00000001, ///< Surface uses preallocated memory
+ rle_optimized = 0x00000002 ///< Surface is RLE optimized for fast blitting
+};
+
+/// Enable bitmask operations for surface_flags
+template <>
+struct enable_bitmask_operators : std::true_type {};
+
+} // namespace laya
diff --git a/include/laya/textures/texture.hpp b/include/laya/textures/texture.hpp
new file mode 100644
index 0000000..559958f
--- /dev/null
+++ b/include/laya/textures/texture.hpp
@@ -0,0 +1,255 @@
+/// RAII wrapper for SDL3 textures with comprehensive texture operations.
+/// \file texture.hpp
+/// \date 2025-09-28
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+struct SDL_Texture;
+
+namespace laya {
+
+// Forward declarations
+class renderer;
+
+/// Arguments for texture construction.
+struct texture_args {
+ /// Pixel format for the texture.
+ pixel_format format = pixel_format::rgba32;
+
+ /// Texture dimensions.
+ dimentions size{0, 0};
+
+ /// Access pattern for the texture.
+ texture_access access = texture_access::static_;
+};
+
+/// RAII guard for texture pixel access during lock operations.
+/// Automatically unlocks the texture on destruction.
+class texture_lock_guard {
+public:
+ /// Locks the texture for pixel access.
+ /// \param tex Texture to lock.
+ /// \param region Optional region to lock.
+ /// \throws laya::error if the texture cannot be locked.
+ explicit texture_lock_guard(class texture& tex, const rect* region = nullptr);
+
+ /// Unlocks the texture automatically.
+ ~texture_lock_guard() noexcept;
+
+ // Non-copyable but movable
+ texture_lock_guard(const texture_lock_guard&) = delete;
+ texture_lock_guard& operator=(const texture_lock_guard&) = delete;
+ texture_lock_guard(texture_lock_guard&& other) noexcept;
+ texture_lock_guard& operator=(texture_lock_guard&& other) noexcept;
+
+ /// Gets raw pixel data pointer.
+ /// \returns Non-owning pointer to pixel data.
+ [[nodiscard]] void* pixels() const noexcept;
+
+ /// Gets texture pitch (bytes per row).
+ /// \returns Number of bytes per row.
+ [[nodiscard]] int pitch() const noexcept;
+
+private:
+ class texture* m_texture{nullptr};
+ void* m_pixels{nullptr};
+ int m_pitch{0};
+};
+
+/// RAII wrapper for SDL3 textures.
+/// Move-only semantics ensure proper resource management.
+class texture {
+public:
+ // ========================================================================
+ // Construction and destruction
+ // ========================================================================
+
+ /// Creates a texture with the specified parameters.
+ /// \param renderer Renderer to create the texture for.
+ /// \param args Texture creation arguments.
+ /// \throws laya::error if texture creation fails.
+ texture(const class renderer& renderer, const texture_args& args);
+
+ /// Creates a texture with the specified parameters.
+ /// \param renderer Renderer to create the texture for.
+ /// \param format Pixel format.
+ /// \param size Texture dimensions.
+ /// \param access Access pattern.
+ /// \throws laya::error if texture creation fails.
+ texture(const class renderer& renderer, pixel_format format, dimentions size,
+ texture_access access = texture_access::static_);
+
+ /// Creates a texture from a surface.
+ /// \param renderer Renderer to create the texture for.
+ /// \param surf Surface to create texture from.
+ /// \throws laya::error if texture creation fails.
+ [[nodiscard]] static texture from_surface(const class renderer& renderer, const surface& surf);
+
+ /// Loads a texture from a BMP file.
+ /// \param renderer Renderer to create the texture for.
+ /// \param path Path to BMP file.
+ /// \returns Loaded texture.
+ /// \throws laya::error if loading fails.
+ [[nodiscard]] static texture load_bmp(const class renderer& renderer, std::string_view path);
+
+ /// Loads a texture from a PNG file (requires SDL_image).
+ /// \param renderer Renderer to create the texture for.
+ /// \param path Path to PNG file.
+ /// \returns Loaded texture.
+ /// \throws laya::error if loading fails.
+ [[nodiscard]] static texture load_png(const class renderer& renderer, std::string_view path);
+
+ /// Destroys the texture and releases resources.
+ ~texture() noexcept;
+
+ // Non-copyable but movable
+ texture(const texture&) = delete;
+ texture& operator=(const texture&) = delete;
+ texture(texture&& other) noexcept;
+ texture& operator=(texture&& other) noexcept;
+
+ // ========================================================================
+ // State management
+ // ========================================================================
+
+ /// Sets alpha modulation for the texture.
+ /// \param alpha Alpha value (0-255).
+ /// \throws laya::error on SDL failure.
+ void set_alpha_mod(std::uint8_t alpha);
+
+ /// Sets alpha modulation for the texture using normalized float (0.0 - 1.0).
+ /// \param alpha Alpha value (0.0-1.0).
+ /// \throws laya::error on SDL failure.
+ void set_alpha_mod(float alpha);
+
+ /// Sets color modulation for the texture.
+ /// \param c Color modulation.
+ /// \throws laya::error on SDL failure.
+ void set_color_mod(color c);
+
+ /// Sets color modulation for the texture.
+ /// \param r Red component (0-255).
+ /// \param g Green component (0-255).
+ /// \param b Blue component (0-255).
+ /// \throws laya::error on SDL failure.
+ void set_color_mod(std::uint8_t r, std::uint8_t g, std::uint8_t b);
+
+ /// Sets color modulation for the texture using normalized floats (0.0 - 1.0).
+ /// \param r Red component (0.0-1.0).
+ /// \param g Green component (0.0-1.0).
+ /// \param b Blue component (0.0-1.0).
+ /// \throws laya::error on SDL failure.
+ void set_color_mod(float r, float g, float b);
+
+ /// Sets blend mode for the texture.
+ /// \param mode Blend mode to use.
+ /// \throws laya::error on SDL failure.
+ void set_blend_mode(blend_mode mode);
+
+ /// Sets scale mode for the texture.
+ /// \param mode Scaling mode to use.
+ /// \throws laya::error on SDL failure.
+ void set_scale_mode(scale_mode mode);
+
+ /// Gets current alpha modulation.
+ /// \returns Alpha value (0-255).
+ /// \throws laya::error on SDL failure.
+ [[nodiscard]] std::uint8_t get_alpha_mod() const;
+
+ /// Gets current alpha modulation as normalized float.
+ /// \returns Alpha value (0.0-1.0).
+ /// \throws laya::error on SDL failure.
+ [[nodiscard]] float get_alpha_mod_float() const;
+
+ /// Gets current color modulation.
+ /// \returns Color modulation values.
+ /// \throws laya::error on SDL failure.
+ [[nodiscard]] color get_color_mod() const;
+
+ /// Gets current color modulation as normalized floats.
+ /// \returns Color modulation values (0.0-1.0).
+ /// \throws laya::error on SDL failure.
+ [[nodiscard]] color_f get_color_mod_float() const;
+
+ /// Gets current blend mode.
+ /// \returns Current blend mode.
+ /// \throws laya::error on SDL failure.
+ [[nodiscard]] blend_mode get_blend_mode() const;
+
+ /// Gets current scale mode.
+ /// \returns Current scale mode.
+ /// \throws laya::error on SDL failure.
+ [[nodiscard]] scale_mode get_scale_mode() const;
+
+ // ========================================================================
+ // Pixel access and updates
+ // ========================================================================
+
+ /// Updates texture pixels from raw data.
+ /// \param pixels Pixel data to upload.
+ /// \param pitch Bytes per row.
+ /// \throws laya::error on SDL failure.
+ void update(const void* pixels, int pitch);
+
+ /// Updates a rectangular region of texture pixels.
+ /// \param r Rectangle to update.
+ /// \param pixels Pixel data to upload.
+ /// \param pitch Bytes per row.
+ /// \throws laya::error on SDL failure.
+ void update(const rect& r, const void* pixels, int pitch);
+ void update(const surface& surf);
+
+ /// Locks the texture for direct pixel access.
+ /// \returns Lock guard that automatically unlocks on destruction.
+ /// \throws laya::error if the texture cannot be locked.
+ texture_lock_guard lock();
+
+ /// Locks a rectangular region for direct pixel access.
+ /// \param r Rectangle to lock.
+ /// \returns Lock guard that automatically unlocks on destruction.
+ /// \throws laya::error if the texture cannot be locked.
+ texture_lock_guard lock(const rect& r);
+
+ // ========================================================================
+ // Query methods
+ // ========================================================================
+
+ /// Gets texture dimensions.
+ /// \returns Width and height of the texture.
+ [[nodiscard]] dimentions size() const noexcept;
+
+ /// Gets texture pixel format.
+ /// \returns Pixel format of the texture.
+ [[nodiscard]] pixel_format format() const noexcept;
+
+ /// Gets texture access pattern.
+ /// \returns Access pattern of the texture.
+ [[nodiscard]] texture_access access() const noexcept;
+
+ /// Gets the native SDL texture handle.
+ /// \returns SDL_Texture pointer for direct SDL operations.
+ [[nodiscard]] SDL_Texture* native_handle() const noexcept;
+
+private:
+ SDL_Texture* m_texture{nullptr};
+ dimentions m_size{0, 0};
+ pixel_format m_format{pixel_format::rgba32};
+ texture_access m_access{texture_access::static_};
+
+ /// Private constructor for factory methods.
+ explicit texture(SDL_Texture* tex);
+
+ friend class texture_lock_guard;
+};
+
+} // namespace laya
diff --git a/include/laya/textures/texture_access.hpp b/include/laya/textures/texture_access.hpp
new file mode 100644
index 0000000..5ca2dab
--- /dev/null
+++ b/include/laya/textures/texture_access.hpp
@@ -0,0 +1,36 @@
+/// Texture access patterns and scaling modes for SDL3 texture operations.
+/// \file texture_access.hpp
+/// \date 2025-09-28
+
+#pragma once
+
+#include
+
+#include
+
+namespace laya {
+
+/// Texture access patterns define how textures can be used.
+/// Maps directly to SDL_TextureAccess values for type safety.
+enum class texture_access : int {
+ /// Texture changes rarely, not lockable.
+ static_ = SDL_TEXTUREACCESS_STATIC,
+
+ /// Texture changes frequently, lockable.
+ streaming = SDL_TEXTUREACCESS_STREAMING,
+
+ /// Texture can be used as a render target.
+ target = SDL_TEXTUREACCESS_TARGET
+};
+
+/// Texture scaling modes for rendering operations.
+/// Maps directly to SDL_ScaleMode values for type safety.
+enum class scale_mode : int {
+ /// Nearest pixel sampling.
+ nearest = SDL_SCALEMODE_NEAREST,
+
+ /// Linear filtering.
+ linear = SDL_SCALEMODE_LINEAR
+};
+
+} // namespace laya
diff --git a/mkdocs.yml b/mkdocs.yml
index 3c56ec0..14c7212 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -67,6 +67,8 @@ nav:
- Features:
- Windows: features/windows.md
- Rendering: features/rendering.md
+ - Surfaces: features/surfaces.md
+ - Textures: features/textures.md
- Events: features/events.md
- Logging: features/logging.md
diff --git a/scripts/README.md b/scripts/README.md
index 1484092..1299e34 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -34,6 +34,35 @@ The script will:
- Start the MkDocs development server with live reload
- Watch for changes and auto-refresh the browser
+## run-pre-commit.sh
+
+Runs pre-commit hooks for the project using uv and the tools environment. This is a convenient wrapper around the pre-commit command referenced in the project's AGENTS.md file.
+
+```bash
+# Run all hooks on all files (default)
+./scripts/run-pre-commit.sh
+
+# Run hooks only on specific files
+./scripts/run-pre-commit.sh --files src/laya/window.cpp
+
+# Run hooks for push stage
+./scripts/run-pre-commit.sh --hook-stage push
+
+# Show diffs when hooks fail
+./scripts/run-pre-commit.sh --show-diff-on-failure
+
+# Get help and see all options
+./scripts/run-pre-commit.sh --help
+```
+
+The script will:
+
+- Execute `uv run --project tools --group dev pre-commit run` with provided arguments
+- Default to `--all-files` when no arguments are given
+- Provide colored output for better readability
+- Pass through all pre-commit flags and options
+- Exit with the same code as the underlying pre-commit command
+
## run-actions.sh
```bash
diff --git a/scripts/run-pre-commit.sh b/scripts/run-pre-commit.sh
new file mode 100755
index 0000000..e4d9189
--- /dev/null
+++ b/scripts/run-pre-commit.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+set -euo pipefail
+IFS=$'\n\t'
+
+# run-pre-commit.sh
+#
+# Runs pre-commit hooks for the project using uv and the tools environment.
+# By default runs all hooks on all files, but can pass additional flags to pre-commit.
+#
+# Usage:
+# ./scripts/run-pre-commit.sh # Run all hooks on all files
+# ./scripts/run-pre-commit.sh --files file.py # Run hooks only on specific files
+# ./scripts/run-pre-commit.sh --hook-stage push # Run hooks for push stage
+# ./scripts/run-pre-commit.sh -h # Show this help
+
+# Color helpers for TTY output
+if [[ -t 1 ]]; then
+ RED="\e[31m"; GREEN="\e[32m"; YELLOW="\e[33m"; CYAN="\e[36m"; MAGENTA="\e[35m"; RESET="\e[0m"
+else
+ RED=""; GREEN=""; YELLOW=""; CYAN=""; MAGENTA=""; RESET=""
+fi
+
+info() { printf "%b[INFO] %s%b\n" "$CYAN" "$*" "$RESET"; }
+success() { printf "%b[SUCCESS] %s%b\n" "$GREEN" "$*" "$RESET"; }
+warn() { printf "%b[WARN] %s%b\n" "$YELLOW" "$*" "$RESET"; }
+error() { printf "%b[ERROR] %s%b\n" "$RED" "$*" "$RESET"; }
+
+usage() {
+ cat << EOF
+run-pre-commit.sh - Run pre-commit hooks for the laya project
+
+SYNOPSIS
+ ./scripts/run-pre-commit.sh [OPTIONS...]
+
+DESCRIPTION
+ Executes pre-commit hooks using uv and the tools environment. By default,
+ runs all hooks on all files. Additional flags are passed through to the
+ pre-commit command.
+
+OPTIONS
+ -h, --help Show this help message
+
+ All other options are passed directly to pre-commit. Common options include:
+ --files FILE... Run hooks only on specified files
+ --hook-stage STAGE Run hooks for specific stage (commit, push, etc.)
+ --all-files Run on all files (default when no args provided)
+ --show-diff-on-failure Show diff when hooks fail
+
+EXAMPLES
+ # Run all hooks on all files (default)
+ ./scripts/run-pre-commit.sh
+
+ # Run hooks only on specific files
+ ./scripts/run-pre-commit.sh --files src/laya/window.cpp
+
+ # Run hooks for push stage
+ ./scripts/run-pre-commit.sh --hook-stage push
+
+ # Show diffs when hooks fail
+ ./scripts/run-pre-commit.sh --show-diff-on-failure
+
+EOF
+}
+
+# Parse command line arguments
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ # All other arguments are passed to pre-commit
+ break
+ ;;
+ esac
+done
+
+# If no arguments provided, default to --all-files
+if [[ $# -eq 0 ]]; then
+ set -- --all-files
+fi
+
+# Check if we're in the project root (look for pyproject.toml in tools/)
+if [[ ! -f "tools/pyproject.toml" ]]; then
+ error "This script must be run from the project root directory"
+ error "Expected to find tools/pyproject.toml in the current directory"
+ exit 1
+fi
+
+# Show what we're about to do
+info "Running pre-commit hooks with: uv run --project tools --group dev pre-commit run $*"
+
+# Execute pre-commit via uv
+if uv run --project tools --group dev pre-commit run "$@"; then
+ success "Pre-commit hooks completed successfully"
+else
+ exit_code=$?
+ error "Pre-commit hooks failed (exit code: $exit_code)"
+ exit $exit_code
+fi
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index dba6257..ceeba2f 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -9,5 +9,7 @@ target_sources(
laya/event_types.cpp
laya/event_polling.cpp
laya/renderer.cpp
+ laya/surface.cpp
+ laya/texture.cpp
laya/log.cpp
)
diff --git a/src/laya/renderer.cpp b/src/laya/renderer.cpp
index e5f0fe2..05a9afc 100644
--- a/src/laya/renderer.cpp
+++ b/src/laya/renderer.cpp
@@ -6,6 +6,7 @@
#include
#include
+#include
#include
namespace laya {
@@ -338,6 +339,96 @@ void renderer::fill_rects(const rect* rects, int count) {
}
}
+// ============================================================================
+// Texture rendering operations
+// ============================================================================
+
+void renderer::render(const texture& tex, point dst_pos) {
+ auto tex_size = tex.size();
+ SDL_FRect dst_rect{static_cast(dst_pos.x), static_cast(dst_pos.y), static_cast(tex_size.width),
+ static_cast(tex_size.height)};
+
+ if (!SDL_RenderTexture(m_renderer, tex.native_handle(), nullptr, &dst_rect)) {
+ throw error("Failed to render texture: {}", SDL_GetError());
+ }
+}
+
+void renderer::render(const texture& tex, const rect& dst_rect) {
+ SDL_FRect sdl_dst{static_cast(dst_rect.x), static_cast(dst_rect.y), static_cast(dst_rect.w),
+ static_cast(dst_rect.h)};
+
+ if (!SDL_RenderTexture(m_renderer, tex.native_handle(), nullptr, &sdl_dst)) {
+ throw error("Failed to render texture: {}", SDL_GetError());
+ }
+}
+
+void renderer::render(const texture& tex, const rect& src_rect, const rect& dst_rect) {
+ SDL_FRect sdl_src{static_cast(src_rect.x), static_cast(src_rect.y), static_cast(src_rect.w),
+ static_cast(src_rect.h)};
+ SDL_FRect sdl_dst{static_cast(dst_rect.x), static_cast(dst_rect.y), static_cast(dst_rect.w),
+ static_cast(dst_rect.h)};
+
+ if (!SDL_RenderTexture(m_renderer, tex.native_handle(), &sdl_src, &sdl_dst)) {
+ throw error("Failed to render texture: {}", SDL_GetError());
+ }
+}
+
+void renderer::render(const texture& tex, const rect& dst_rect, double angle) {
+ SDL_FRect sdl_dst{static_cast(dst_rect.x), static_cast(dst_rect.y), static_cast(dst_rect.w),
+ static_cast(dst_rect.h)};
+
+ if (!SDL_RenderTextureRotated(m_renderer, tex.native_handle(), nullptr, &sdl_dst, angle, nullptr, SDL_FLIP_NONE)) {
+ throw error("Failed to render rotated texture: {}", SDL_GetError());
+ }
+}
+
+void renderer::render(const texture& tex, const rect& dst_rect, double angle, point center) {
+ SDL_FRect sdl_dst{static_cast(dst_rect.x), static_cast(dst_rect.y), static_cast(dst_rect.w),
+ static_cast(dst_rect.h)};
+ SDL_FPoint sdl_center{static_cast(center.x), static_cast(center.y)};
+
+ if (!SDL_RenderTextureRotated(m_renderer, tex.native_handle(), nullptr, &sdl_dst, angle, &sdl_center,
+ SDL_FLIP_NONE)) {
+ throw error("Failed to render rotated texture: {}", SDL_GetError());
+ }
+}
+
+void renderer::render(const texture& tex, const rect& src_rect, const rect& dst_rect, double angle, point center,
+ flip_mode flip) {
+ SDL_FRect sdl_src{static_cast(src_rect.x), static_cast(src_rect.y), static_cast(src_rect.w),
+ static_cast(src_rect.h)};
+ SDL_FRect sdl_dst{static_cast(dst_rect.x), static_cast(dst_rect.y), static_cast(dst_rect.w),
+ static_cast(dst_rect.h)};
+ SDL_FPoint sdl_center{static_cast(center.x), static_cast(center.y)};
+
+ if (!SDL_RenderTextureRotated(m_renderer, tex.native_handle(), &sdl_src, &sdl_dst, angle, &sdl_center,
+ static_cast(flip))) {
+ throw error("Failed to render rotated texture: {}", SDL_GetError());
+ }
+}
+
+void renderer::render(const texture& tex, const rect& dst_rect, flip_mode flip) {
+ SDL_FRect sdl_dst{static_cast(dst_rect.x), static_cast(dst_rect.y), static_cast(dst_rect.w),
+ static_cast(dst_rect.h)};
+
+ if (!SDL_RenderTextureRotated(m_renderer, tex.native_handle(), nullptr, &sdl_dst, 0.0, nullptr,
+ static_cast(flip))) {
+ throw error("Failed to render flipped texture: {}", SDL_GetError());
+ }
+}
+
+void renderer::render(const texture& tex, const rect& src_rect, const rect& dst_rect, flip_mode flip) {
+ SDL_FRect sdl_src{static_cast(src_rect.x), static_cast(src_rect.y), static_cast(src_rect.w),
+ static_cast(src_rect.h)};
+ SDL_FRect sdl_dst{static_cast(dst_rect.x), static_cast(dst_rect.y), static_cast(dst_rect.w),
+ static_cast(dst_rect.h)};
+
+ if (!SDL_RenderTextureRotated(m_renderer, tex.native_handle(), &sdl_src, &sdl_dst, 0.0, nullptr,
+ static_cast(flip))) {
+ throw error("Failed to render flipped texture: {}", SDL_GetError());
+ }
+}
+
// ============================================================================
// RAII state guard implementations
// ============================================================================
diff --git a/src/laya/surface.cpp b/src/laya/surface.cpp
new file mode 100644
index 0000000..d4db81f
--- /dev/null
+++ b/src/laya/surface.cpp
@@ -0,0 +1,350 @@
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace std::string_view_literals;
+
+namespace laya {
+
+// ============================================================================
+// surface_lock_guard implementation
+// ============================================================================
+
+surface_lock_guard::surface_lock_guard(class surface& surf) : m_surface(&surf) {
+ if (!SDL_LockSurface(surf.native_handle())) {
+ throw error::from_sdl();
+ }
+ m_pixels = surf.native_handle()->pixels;
+ m_pitch = surf.native_handle()->pitch;
+}
+
+surface_lock_guard::surface_lock_guard(surface_lock_guard&& other) noexcept
+ : m_surface{std::exchange(other.m_surface, nullptr)}, m_pixels{other.m_pixels}, m_pitch{other.m_pitch} {
+}
+
+surface_lock_guard& surface_lock_guard::operator=(surface_lock_guard&& other) noexcept {
+ if (this != &other) {
+ if (m_surface) {
+ SDL_UnlockSurface(m_surface->native_handle());
+ }
+ m_surface = std::exchange(other.m_surface, nullptr);
+ m_pixels = other.m_pixels;
+ m_pitch = other.m_pitch;
+ }
+ return *this;
+}
+
+surface_lock_guard::~surface_lock_guard() noexcept {
+ if (m_surface) {
+ SDL_UnlockSurface(m_surface->native_handle());
+ }
+}
+
+void* surface_lock_guard::pixels() const noexcept {
+ return m_pixels;
+}
+
+int surface_lock_guard::pitch() const noexcept {
+ return m_pitch;
+}
+
+// ============================================================================
+// surface implementation
+// ============================================================================
+
+surface::surface(const surface_args& args) {
+ if ((args.flags & surface_flags::preallocated) == surface_flags::preallocated) {
+ throw std::runtime_error(
+ "surface_flags::preallocated is not supported yet; supply external pixel memory before enabling this flag");
+ }
+
+ m_surface = SDL_CreateSurface(args.size.width, args.size.height, static_cast(args.format));
+
+ if (!m_surface) {
+ throw error::from_sdl();
+ }
+
+ if ((args.flags & surface_flags::rle_optimized) == surface_flags::rle_optimized) {
+ if (!SDL_SetSurfaceRLE(m_surface, true)) {
+ throw error::from_sdl();
+ }
+ }
+}
+
+surface::surface(dimentions size, pixel_format format) {
+ m_surface = SDL_CreateSurface(size.width, size.height, static_cast(format));
+
+ if (!m_surface) {
+ throw error::from_sdl();
+ }
+}
+
+surface surface::load_bmp(std::string_view path) {
+ SDL_Surface* surf = SDL_LoadBMP(std::string(path).c_str());
+ if (!surf) {
+ throw error::from_sdl();
+ }
+ return surface(surf);
+}
+
+surface surface::load_png(std::string_view path) {
+ // Note: This requires SDL_image
+ // SDL_Surface* surf = IMG_Load(std::string(path).c_str());
+ // For now, throw an error indicating PNG support requires SDL_image
+ (void)path; // Mark parameter as used
+ throw error("PNG loading requires SDL_image library - not yet implemented: {}", "feature not available");
+}
+
+surface::~surface() noexcept {
+ if (m_surface) {
+ SDL_DestroySurface(m_surface);
+ }
+}
+
+surface::surface(surface&& other) noexcept : m_surface{std::exchange(other.m_surface, nullptr)} {
+}
+
+surface& surface::operator=(surface&& other) noexcept {
+ if (this != &other) {
+ if (m_surface) {
+ SDL_DestroySurface(m_surface);
+ }
+ m_surface = std::exchange(other.m_surface, nullptr);
+ }
+ return *this;
+}
+
+void surface::fill(color c) {
+ const std::uint32_t mapped_color = SDL_MapSurfaceRGBA(m_surface, c.r, c.g, c.b, c.a);
+
+ if (!SDL_FillSurfaceRect(m_surface, nullptr, mapped_color)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::fill_rect(const rect& r, color c) {
+ const SDL_Rect sdl_rect{r.x, r.y, r.w, r.h};
+ const std::uint32_t mapped_color = SDL_MapSurfaceRGBA(m_surface, c.r, c.g, c.b, c.a);
+
+ if (!SDL_FillSurfaceRect(m_surface, &sdl_rect, mapped_color)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::fill_rects(std::span rects, color c) {
+ const std::uint32_t mapped_color = SDL_MapSurfaceRGBA(m_surface, c.r, c.g, c.b, c.a);
+
+ // Convert laya rects to SDL rects
+ std::vector sdl_rects;
+ sdl_rects.reserve(rects.size());
+
+ std::transform(rects.begin(), rects.end(), std::back_inserter(sdl_rects),
+ [](const rect& r) { return SDL_Rect{r.x, r.y, r.w, r.h}; });
+
+ if (!SDL_FillSurfaceRects(m_surface, sdl_rects.data(), static_cast(sdl_rects.size()), mapped_color)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::clear() {
+ if (!SDL_ClearSurface(m_surface, 0.0f, 0.0f, 0.0f, 0.0f)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::blit(const surface& src, const rect& src_rect, const rect& dst_rect) {
+ const SDL_Rect sdl_src_rect{src_rect.x, src_rect.y, src_rect.w, src_rect.h};
+ const SDL_Rect sdl_dst_rect{dst_rect.x, dst_rect.y, dst_rect.w, dst_rect.h};
+
+ if (!SDL_BlitSurface(src.m_surface, &sdl_src_rect, m_surface, &sdl_dst_rect)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::blit(const surface& src, point dst_pos) {
+ SDL_Rect sdl_dst_rect{dst_pos.x, dst_pos.y, 0, 0};
+
+ if (!SDL_BlitSurface(src.m_surface, nullptr, m_surface, &sdl_dst_rect)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::set_alpha_mod(std::uint8_t alpha) {
+ if (!SDL_SetSurfaceAlphaMod(m_surface, alpha)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::set_color_mod(color c) {
+ if (!SDL_SetSurfaceColorMod(m_surface, c.r, c.g, c.b)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::set_color_mod(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
+ if (!SDL_SetSurfaceColorMod(m_surface, r, g, b)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::set_blend_mode(blend_mode mode) {
+ if (!SDL_SetSurfaceBlendMode(m_surface, static_cast(mode))) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::set_color_key(color key) {
+ const std::uint32_t mapped_key = SDL_MapSurfaceRGBA(m_surface, key.r, key.g, key.b, key.a);
+ if (!SDL_SetSurfaceColorKey(m_surface, true, mapped_key)) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::clear_color_key() {
+ if (!SDL_SetSurfaceColorKey(m_surface, false, 0)) {
+ throw error::from_sdl();
+ }
+}
+
+std::uint8_t surface::get_alpha_mod() const {
+ std::uint8_t alpha;
+ if (!SDL_GetSurfaceAlphaMod(m_surface, &alpha)) {
+ throw error::from_sdl();
+ }
+ return alpha;
+}
+
+color surface::get_color_mod() const {
+ std::uint8_t r, g, b;
+ if (!SDL_GetSurfaceColorMod(m_surface, &r, &g, &b)) {
+ throw error::from_sdl();
+ }
+ return color{r, g, b};
+}
+
+blend_mode surface::get_blend_mode() const {
+ SDL_BlendMode mode;
+ if (!SDL_GetSurfaceBlendMode(m_surface, &mode)) {
+ throw error::from_sdl();
+ }
+ return static_cast(mode);
+}
+
+bool surface::has_color_key() const {
+ return SDL_SurfaceHasColorKey(m_surface);
+}
+
+color surface::get_color_key() const {
+ std::uint32_t key;
+ if (!SDL_GetSurfaceColorKey(m_surface, &key)) {
+ throw error::from_sdl();
+ }
+
+ auto* format_details = SDL_GetPixelFormatDetails(m_surface->format);
+ if (!format_details) {
+ throw error::from_sdl();
+ }
+
+ std::uint8_t r{}, g{}, b{}, a{};
+ SDL_GetRGBA(key, format_details, nullptr, &r, &g, &b, &a);
+ return color{r, g, b, a};
+}
+
+dimentions surface::size() const noexcept {
+ return {m_surface->w, m_surface->h};
+}
+
+pixel_format surface::format() const noexcept {
+ return static_cast(m_surface->format);
+}
+
+bool surface::must_lock() const noexcept {
+ // SDL3 surfaces typically do not require locking; retain compatibility hook
+ return false;
+}
+
+surface surface::convert(pixel_format format) const {
+ SDL_Surface* converted = SDL_ConvertSurface(m_surface, static_cast(format));
+ if (!converted) {
+ throw error::from_sdl();
+ }
+ return surface(converted);
+}
+
+surface surface::duplicate() const {
+ SDL_Surface* duplicated = SDL_DuplicateSurface(m_surface);
+ if (!duplicated) {
+ throw error::from_sdl();
+ }
+ return surface(duplicated);
+}
+
+surface surface::scale(dimentions new_size) const {
+ SDL_Surface* scaled = SDL_ScaleSurface(m_surface, new_size.width, new_size.height, SDL_SCALEMODE_LINEAR);
+ if (!scaled) {
+ throw error::from_sdl();
+ }
+ return surface(scaled);
+}
+
+surface surface::flip(flip_mode mode) const {
+ // Create a duplicate first, then flip it in place
+ SDL_Surface* flipped = SDL_DuplicateSurface(m_surface);
+ if (!flipped) {
+ throw error::from_sdl();
+ }
+
+ SDL_FlipMode sdl_flip = SDL_FLIP_NONE;
+ switch (mode) {
+ case flip_mode::horizontal:
+ sdl_flip = SDL_FLIP_HORIZONTAL;
+ break;
+ case flip_mode::vertical:
+ sdl_flip = SDL_FLIP_VERTICAL;
+ break;
+ case flip_mode::none:
+ default:
+ // No flipping needed, just return the duplicate
+ return surface(flipped);
+ }
+
+ if (!SDL_FlipSurface(flipped, sdl_flip)) {
+ SDL_DestroySurface(flipped);
+ throw error::from_sdl();
+ }
+ return surface(flipped);
+}
+
+surface_lock_guard surface::lock() {
+ return surface_lock_guard(*this);
+}
+
+void surface::save_bmp(std::string_view path) const {
+ if (!SDL_SaveBMP(m_surface, std::string(path).c_str())) {
+ throw error::from_sdl();
+ }
+}
+
+void surface::save_png(std::string_view path) const {
+ // Note: PNG saving requires SDL_image
+ // IMG_SavePNG(m_surface, std::string(path).c_str())
+ (void)path; // Mark parameter as used
+ throw error("PNG saving requires SDL_image library - not yet implemented: {}", "feature not available");
+}
+
+SDL_Surface* surface::native_handle() const noexcept {
+ return m_surface;
+}
+
+surface::surface(SDL_Surface* surf) : m_surface(surf) {
+ // Private constructor for factory methods - takes ownership
+}
+
+} // namespace laya
diff --git a/src/laya/texture.cpp b/src/laya/texture.cpp
new file mode 100644
index 0000000..0c8bbbf
--- /dev/null
+++ b/src/laya/texture.cpp
@@ -0,0 +1,268 @@
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+using namespace std::string_view_literals;
+
+namespace laya {
+
+// ============================================================================
+// texture_lock_guard implementation
+// ============================================================================
+
+texture_lock_guard::texture_lock_guard(class texture& tex, const rect* region) : m_texture(&tex) {
+ SDL_Rect sdl_rect{};
+ SDL_Rect* rect_ptr = nullptr;
+ if (region) {
+ sdl_rect = SDL_Rect{region->x, region->y, region->w, region->h};
+ rect_ptr = &sdl_rect;
+ }
+
+ if (!SDL_LockTexture(tex.native_handle(), rect_ptr, &m_pixels, &m_pitch)) {
+ throw error::from_sdl();
+ }
+}
+
+texture_lock_guard::texture_lock_guard(texture_lock_guard&& other) noexcept
+ : m_texture{std::exchange(other.m_texture, nullptr)}, m_pixels{other.m_pixels}, m_pitch{other.m_pitch} {
+}
+
+texture_lock_guard& texture_lock_guard::operator=(texture_lock_guard&& other) noexcept {
+ if (this != &other) {
+ if (m_texture) {
+ SDL_UnlockTexture(m_texture->native_handle());
+ }
+ m_texture = std::exchange(other.m_texture, nullptr);
+ m_pixels = other.m_pixels;
+ m_pitch = other.m_pitch;
+ }
+ return *this;
+}
+
+texture_lock_guard::~texture_lock_guard() noexcept {
+ if (m_texture) {
+ SDL_UnlockTexture(m_texture->native_handle());
+ }
+}
+
+void* texture_lock_guard::pixels() const noexcept {
+ return m_pixels;
+}
+
+int texture_lock_guard::pitch() const noexcept {
+ return m_pitch;
+}
+
+// ============================================================================
+// texture implementation
+// ============================================================================
+
+texture::texture(const class renderer& renderer, const texture_args& args)
+ : texture(renderer, args.format, args.size, args.access) {
+}
+
+texture::texture(const class renderer& renderer, pixel_format format, dimentions size, texture_access access)
+ : m_size{size}, m_format{format}, m_access{access} {
+ m_texture = SDL_CreateTexture(renderer.native_handle(), static_cast(format),
+ static_cast(access), size.width, size.height);
+
+ if (!m_texture) {
+ throw error::from_sdl();
+ }
+}
+
+texture texture::from_surface(const class renderer& renderer, const surface& surf) {
+ SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer.native_handle(), surf.native_handle());
+ if (!tex) {
+ throw error::from_sdl();
+ }
+
+ texture result(tex);
+ SDL_PropertiesID props = SDL_GetTextureProperties(tex);
+ if (props) {
+ result.m_size.width = SDL_GetNumberProperty(props, SDL_PROP_TEXTURE_WIDTH_NUMBER, surf.size().width);
+ result.m_size.height = SDL_GetNumberProperty(props, SDL_PROP_TEXTURE_HEIGHT_NUMBER, surf.size().height);
+ result.m_format = static_cast(
+ SDL_GetNumberProperty(props, SDL_PROP_TEXTURE_FORMAT_NUMBER, static_cast(surf.format())));
+ result.m_access = static_cast(
+ SDL_GetNumberProperty(props, SDL_PROP_TEXTURE_ACCESS_NUMBER, SDL_TEXTUREACCESS_STATIC));
+ } else {
+ result.m_size = surf.size();
+ result.m_format = surf.format();
+ result.m_access = texture_access::static_;
+ }
+
+ return result;
+}
+
+texture texture::load_bmp(const class renderer& renderer, std::string_view path) {
+ // Load surface first, then convert to texture
+ auto surf = surface::load_bmp(path);
+ return from_surface(renderer, surf);
+}
+
+texture texture::load_png(const class renderer& renderer, std::string_view path) {
+ // Load surface first, then convert to texture
+ auto surf = surface::load_png(path); // This will throw since PNG isn't implemented yet
+ return from_surface(renderer, surf);
+}
+
+texture::~texture() noexcept {
+ if (m_texture) {
+ SDL_DestroyTexture(m_texture);
+ }
+}
+
+texture::texture(texture&& other) noexcept
+ : m_texture{std::exchange(other.m_texture, nullptr)},
+ m_size{std::exchange(other.m_size, dimentions{0, 0})},
+ m_format{std::exchange(other.m_format, pixel_format::rgba32)},
+ m_access{std::exchange(other.m_access, texture_access::static_)} {
+}
+
+texture& texture::operator=(texture&& other) noexcept {
+ if (this != &other) {
+ if (m_texture) {
+ SDL_DestroyTexture(m_texture);
+ }
+ m_texture = std::exchange(other.m_texture, nullptr);
+ m_size = std::exchange(other.m_size, dimentions{0, 0});
+ m_format = std::exchange(other.m_format, pixel_format::rgba32);
+ m_access = std::exchange(other.m_access, texture_access::static_);
+ }
+ return *this;
+}
+
+void texture::update(const void* pixels, int pitch) {
+ if (!SDL_UpdateTexture(m_texture, nullptr, pixels, pitch)) {
+ throw error::from_sdl();
+ }
+}
+
+void texture::update(const rect& r, const void* pixels, int pitch) {
+ const SDL_Rect sdl_rect{r.x, r.y, r.w, r.h};
+ if (!SDL_UpdateTexture(m_texture, &sdl_rect, pixels, pitch)) {
+ throw error::from_sdl();
+ }
+}
+
+void texture::update(const surface& surf) {
+ if (!SDL_UpdateTexture(m_texture, nullptr, surf.native_handle()->pixels, surf.native_handle()->pitch)) {
+ throw error::from_sdl();
+ }
+}
+
+texture_lock_guard texture::lock() {
+ return texture_lock_guard(*this, nullptr);
+}
+
+texture_lock_guard texture::lock(const rect& r) {
+ return texture_lock_guard(*this, &r);
+}
+
+void texture::set_alpha_mod(std::uint8_t alpha) {
+ if (!SDL_SetTextureAlphaMod(m_texture, alpha)) {
+ throw error::from_sdl();
+ }
+}
+
+void texture::set_alpha_mod(float alpha) {
+ set_alpha_mod(static_cast(alpha * 255.0f));
+}
+
+void texture::set_color_mod(color c) {
+ if (!SDL_SetTextureColorMod(m_texture, c.r, c.g, c.b)) {
+ throw error::from_sdl();
+ }
+}
+
+void texture::set_color_mod(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
+ if (!SDL_SetTextureColorMod(m_texture, r, g, b)) {
+ throw error::from_sdl();
+ }
+}
+
+void texture::set_color_mod(float r, float g, float b) {
+ set_color_mod(static_cast(r * 255.0f), static_cast(g * 255.0f),
+ static_cast(b * 255.0f));
+}
+
+void texture::set_blend_mode(blend_mode mode) {
+ if (!SDL_SetTextureBlendMode(m_texture, static_cast(mode))) {
+ throw error::from_sdl();
+ }
+}
+
+void texture::set_scale_mode(scale_mode mode) {
+ if (!SDL_SetTextureScaleMode(m_texture, static_cast(mode))) {
+ throw error::from_sdl();
+ }
+}
+
+float texture::get_alpha_mod_float() const {
+ return static_cast(get_alpha_mod()) / 255.0f;
+}
+
+color_f texture::get_color_mod_float() const {
+ auto c = get_color_mod();
+ return color_f{static_cast(c.r) / 255.0f, static_cast(c.g) / 255.0f, static_cast(c.b) / 255.0f,
+ 1.0f};
+}
+
+std::uint8_t texture::get_alpha_mod() const {
+ std::uint8_t alpha;
+ if (!SDL_GetTextureAlphaMod(m_texture, &alpha)) {
+ throw error::from_sdl();
+ }
+ return alpha;
+}
+
+color texture::get_color_mod() const {
+ std::uint8_t r, g, b;
+ if (!SDL_GetTextureColorMod(m_texture, &r, &g, &b)) {
+ throw error::from_sdl();
+ }
+ return color{r, g, b};
+}
+
+blend_mode texture::get_blend_mode() const {
+ SDL_BlendMode mode;
+ if (!SDL_GetTextureBlendMode(m_texture, &mode)) {
+ throw error::from_sdl();
+ }
+ return static_cast(mode);
+}
+
+scale_mode texture::get_scale_mode() const {
+ SDL_ScaleMode mode;
+ if (!SDL_GetTextureScaleMode(m_texture, &mode)) {
+ throw error::from_sdl();
+ }
+ return static_cast(mode);
+}
+
+dimentions texture::size() const noexcept {
+ return m_size;
+}
+
+pixel_format texture::format() const noexcept {
+ return m_format;
+}
+
+texture_access texture::access() const noexcept {
+ return m_access;
+}
+
+SDL_Texture* texture::native_handle() const noexcept {
+ return m_texture;
+}
+
+texture::texture(SDL_Texture* tex) : m_texture(tex) {
+ // Private constructor for factory methods - takes ownership
+}
+
+} // namespace laya
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 9495c6d..8c43412 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -39,6 +39,7 @@ if(LAYA_TESTS_UNIT)
unit/test_event_range.cpp
unit/test_window_event.cpp
unit/test_logging.cpp
+ unit/test_surface.cpp
)
# Create unit test executable
diff --git a/tests/unit/test_surface.cpp b/tests/unit/test_surface.cpp
new file mode 100644
index 0000000..cb5356a
--- /dev/null
+++ b/tests/unit/test_surface.cpp
@@ -0,0 +1,161 @@
+/// @file test_surface.cpp
+/// @brief Basic unit tests for surface API
+/// @date 2025-12-10
+
+#include
+#include
+
+using namespace laya;
+
+TEST_SUITE("Surface") {
+ // Helper function to create a test surface
+ auto create_test_surface = [](dimentions size = {64, 64}, pixel_format fmt = pixel_format::rgba32) {
+ return surface{size, fmt};
+ };
+
+ TEST_CASE("Surface creation - Basic surface creation") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface();
+
+ CHECK(surf.size().width == 64);
+ CHECK(surf.size().height == 64);
+ CHECK(surf.format() == pixel_format::rgba32);
+ CHECK(surf.native_handle() != nullptr);
+ }
+
+ TEST_CASE("Surface creation - Surface creation with args") {
+ laya::context ctx{laya::subsystem::video};
+ surface_args args{.size = {128, 256}, .format = pixel_format::bgra32, .flags = surface_flags::rle_optimized};
+
+ laya::surface surf{args};
+
+ CHECK(surf.size().width == 128);
+ CHECK(surf.size().height == 256);
+ CHECK(surf.format() == pixel_format::bgra32);
+ }
+
+ TEST_CASE("Surface operations - Fill operations") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({32, 32});
+
+ // These should not throw
+ CHECK_NOTHROW(surf.fill(laya::colors::red));
+ CHECK_NOTHROW(surf.fill_rect({0, 0, 16, 16}, laya::colors::blue));
+ CHECK_NOTHROW(surf.clear());
+ }
+
+ TEST_CASE("Surface operations - State management") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({32, 32});
+
+ // These should not throw
+ CHECK_NOTHROW(surf.set_alpha_mod(128));
+ CHECK_NOTHROW(surf.set_color_mod(laya::colors::green));
+ CHECK_NOTHROW(surf.set_blend_mode(laya::blend_mode::blend));
+
+ // Verify getters work
+ CHECK(surf.get_alpha_mod() == 128);
+ CHECK(surf.get_color_mod() == laya::colors::green);
+ CHECK(surf.get_blend_mode() == laya::blend_mode::blend);
+ }
+
+ TEST_CASE("Surface operations - Color key operations") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({32, 32});
+
+ CHECK_FALSE(surf.has_color_key());
+
+ CHECK_NOTHROW(surf.set_color_key(laya::colors::magenta));
+ CHECK(surf.has_color_key());
+ CHECK(surf.get_color_key() == laya::colors::magenta);
+
+ CHECK_NOTHROW(surf.clear_color_key());
+ CHECK_FALSE(surf.has_color_key());
+ }
+
+ TEST_CASE("Surface transformations - Duplicate") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({16, 16});
+
+ auto dup = surf.duplicate();
+ CHECK(dup.size().width == surf.size().width);
+ CHECK(dup.size().height == surf.size().height);
+ CHECK(dup.format() == surf.format());
+ CHECK(dup.native_handle() != surf.native_handle()); // Different objects
+ }
+
+ TEST_CASE("Surface transformations - Convert format") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({16, 16});
+
+ auto converted = surf.convert(pixel_format::bgra32);
+ CHECK(converted.size().width == surf.size().width);
+ CHECK(converted.size().height == surf.size().height);
+ CHECK(converted.format() == pixel_format::bgra32);
+ }
+
+ TEST_CASE("Surface transformations - Scale") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({16, 16});
+
+ auto scaled = surf.scale({32, 32});
+ CHECK(scaled.size().width == 32);
+ CHECK(scaled.size().height == 32);
+ CHECK(scaled.format() == surf.format());
+ }
+
+ TEST_CASE("Surface transformations - Flip") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({16, 16});
+
+ auto flipped_h = surf.flip(flip_mode::horizontal);
+ auto flipped_v = surf.flip(flip_mode::vertical);
+ auto flipped_none = surf.flip(flip_mode::none);
+
+ // All should have same size and format
+ CHECK(flipped_h.size().width == surf.size().width);
+ CHECK(flipped_h.size().height == surf.size().height);
+ CHECK(flipped_v.size().width == surf.size().width);
+ CHECK(flipped_v.size().height == surf.size().height);
+ CHECK(flipped_none.size().width == surf.size().width);
+ CHECK(flipped_none.size().height == surf.size().height);
+
+ CHECK(flipped_h.format() == surf.format());
+ CHECK(flipped_v.format() == surf.format());
+ CHECK(flipped_none.format() == surf.format());
+ }
+
+ TEST_CASE("Surface locking - Basic lock guard usage") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({8, 8});
+
+ {
+ auto lock = surf.lock();
+ CHECK(lock.pixels() != nullptr);
+ CHECK(lock.pitch() > 0);
+ }
+ // Lock should be automatically released
+ }
+
+ TEST_CASE("Surface locking - Lock guard move semantics") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({8, 8});
+
+ auto lock1 = surf.lock();
+ void* original_pixels = lock1.pixels();
+
+ auto lock2 = std::move(lock1);
+ CHECK(lock2.pixels() == original_pixels);
+ // lock1 should be empty after move
+ }
+
+ TEST_CASE("Surface metadata") {
+ laya::context ctx{laya::subsystem::video};
+ auto surf = create_test_surface({42, 24}, pixel_format::rgb24);
+
+ CHECK(surf.size().width == 42);
+ CHECK(surf.size().height == 24);
+ CHECK(surf.format() == pixel_format::rgb24);
+ CHECK_FALSE(surf.must_lock()); // SDL3 typically doesn't require locking
+ }
+}