Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions include/cute_draw.h
Original file line number Diff line number Diff line change
Expand Up @@ -1843,6 +1843,8 @@ CF_API bool CF_CALL cf_draw_peek_alpha_discard(void);
CF_ENUM(DRAW_FILTER_LINEAR, 1) \
/* @entry Smooth filtering. Uses hardware linear combined with a shader-based smooth UV interpolation. */ \
CF_ENUM(DRAW_FILTER_SMOOTH, 2) \
/* @entry Pixel art filtering, a port of SDL's `SDL_SCALEMODE_PIXELART` box-filter. Like SMOOTH but with hard sprite edges, so adjacent tiles stay gap-free under subpixel camera motion. */ \
CF_ENUM(DRAW_FILTER_PIXELART, 3) \
/* @end */

typedef enum CF_DrawFilterMode
Expand Down Expand Up @@ -1874,6 +1876,11 @@ CF_INLINE const char* cf_draw_filter_mode_to_string(CF_DrawFilterMode mode) {
* @param mode The filter mode to use. See `CF_DrawFilterMode`.
* @remarks NEAREST uses hardware nearest-neighbor filtering, good for pixel art. LINEAR uses hardware bilinear filtering.
* SMOOTH uses hardware linear combined with shader-based smooth UV interpolation for high quality upscaling.
* PIXELART uses hardware linear combined with a shader-based box-filter approximation, ported from SDL's
* `SDL_SCALEMODE_PIXELART`. Unlike SMOOTH, sprite edges are hard (a pixel belongs to exactly one sprite),
* which keeps adjacent tiles seam-free when the camera moves by subpixel amounts -- use it for tilemaps.
* SMOOTH's antialiased sprite edges cannot tile seamlessly: two half-covered edges blend to 75% coverage,
* so the background bleeds through at tile seams under subpixel motion.
* @related CF_DrawFilterMode cf_draw_push_filter cf_draw_pop_filter cf_draw_peek_filter
*/
CF_API void CF_CALL cf_draw_push_filter(CF_DrawFilterMode mode);
Expand Down
2 changes: 2 additions & 0 deletions samples/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ add_sample(outline_stencil outline_stencil.cpp)
add_sample(recolor recolor.cpp)
add_sample(rainbow_liquid rainbow_liquid.cpp)
add_sample(import_spritesheet import_spritesheet.cpp)
add_sample(tilefilters tile_filters.c)
add_sample(stencil_pie_chart stencil_pie_chart.c)
add_sample(platformer platformer.cpp)
add_sample(polygon polygon.cpp)
Expand Down Expand Up @@ -137,6 +138,7 @@ add_custom_command(TARGET waves PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy_direc
add_custom_command(TARGET fluid_sim PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/fluid_sim_data $<TARGET_FILE_DIR:fluid_sim>/fluid_sim_data)
add_custom_command(TARGET shallow_water PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/shallow_water_data $<TARGET_FILE_DIR:shallow_water>/shallow_water_data)
add_custom_command(TARGET import_spritesheet PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/import_spritesheet_data $<TARGET_FILE_DIR:import_spritesheet>/import_spritesheet_data)
add_custom_command(TARGET tilefilters PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/import_spritesheet_data $<TARGET_FILE_DIR:tilefilters>/import_spritesheet_data)
add_custom_command(TARGET pivot PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/pivot_data $<TARGET_FILE_DIR:pivot>/pivot_data)
add_custom_command(TARGET scratch PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/../assets/CF_Logo_Pixel.png $<TARGET_FILE_DIR:scratch>/app_icon.png)
add_custom_command(TARGET 9_slice PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/9_slice_data $<TARGET_FILE_DIR:9_slice>/9_slice_data)
Expand Down
275 changes: 275 additions & 0 deletions samples/tile_filters.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/*
Demonstrates CF's draw filter modes on a tilemap under subpixel camera motion.

Adjacent 16x16 tiles are drawn as individual sprites while the camera slowly pans by
fractions of a pixel. This exposes seam artifacts between tiles: with SMOOTH/LINEAR the
tile edges go semi-transparent at subpixel offsets and the background bleeds through.
PIXELART (a port of SDL's SDL_SCALEMODE_PIXELART box-filter) uses hard sprite edges and
clamp-to-edge sampling instead, so tiles stay gap-free at any camera offset or zoom.

Controls:
SPACE cycle filter mode
P pause/resume the camera pan
UP/DOWN zoom in/out
S save one png per filter mode (current camera offset)

A 5x3 tile block slowly rotates in the sky: the pixel art filters should keep its
interior pixels stable (no shimmering crawl like NEAREST) and its seams closed even
though the shared tile edges are not axis-aligned.

Run `tilefilters screenshot <prefix> [offset_px] [zoom] [angle_deg]` to save
deterministic pngs of every filter mode at the given subpixel camera offset (default
0.5, worst case for seams) plus a zero-offset control, then exit.
*/

#include <cute.h>
#include <stdio.h>
#include <stdlib.h>

#define TILE 16
#define MAP_W 32
#define MAP_H 12
#define CANVAS_W 1280
#define CANVAS_H 720

typedef enum TileKind
{
TILE_NONE,
TILE_GRASS_L,
TILE_GRASS_M,
TILE_GRASS_R,
TILE_DIRT,
TILE_KIND_COUNT,
} TileKind;

void mount_content_directory_as(const char* dir)
{
// cf_string_append reassigns the dynamic string in place: one allocation, one free.
char* path = cf_path_normalize(cf_fs_get_base_directory());
cf_string_append(path, "/import_spritesheet_data");
cf_fs_mount(path, dir, false);
cf_string_free(path);
}

// Cut one 16x16 tile out of the sheet as its own easy sprite. Each tile becomes a separate
// atlas entry, which is how a real tilemap hits the atlas border-pixel behavior.
static CF_Sprite s_cut_tile(const CF_Image* sheet, int col, int row)
{
CF_Pixel px[TILE * TILE];
for (int y = 0; y < TILE; ++y) {
for (int x = 0; x < TILE; ++x) {
px[y * TILE + x] = sheet->pix[(row * TILE + y) * sheet->w + (col * TILE + x)];
}
}
return cf_make_easy_sprite_from_pixels(px, TILE, TILE);
}

static CF_Sprite s_tiles[TILE_KIND_COUNT];
static TileKind s_map[MAP_H][MAP_W];

static void s_build_map(void)
{
for (int y = 0; y < MAP_H; ++y) {
for (int x = 0; x < MAP_W; ++x) {
if (y == 4) s_map[y][x] = TILE_GRASS_M;
else if (y > 4) s_map[y][x] = TILE_DIRT;
else s_map[y][x] = TILE_NONE;
}
}
// A floating platform so isolated caps (transparent rounded corners) are visible too.
s_map[1][10] = TILE_GRASS_L;
s_map[1][11] = TILE_GRASS_M;
s_map[1][12] = TILE_GRASS_M;
s_map[1][13] = TILE_GRASS_R;
}

// A 5x3 tile block rotating about its center. Verifies two things under rotation: the
// interior stays pixel-stable (the point of the pixel art filters), and adjacent tiles
// stay seam-free even when the shared edges are not axis-aligned.
static void s_draw_rotated_block(float angle)
{
static const TileKind block[3][5] = {
{ TILE_GRASS_L, TILE_GRASS_M, TILE_GRASS_M, TILE_GRASS_M, TILE_GRASS_R },
{ TILE_DIRT, TILE_DIRT, TILE_DIRT, TILE_DIRT, TILE_DIRT },
{ TILE_DIRT, TILE_DIRT, TILE_DIRT, TILE_DIRT, TILE_DIRT },
};
CF_V2 center = cf_v2(120, 56);
CF_SinCos r = cf_sincos(angle);
for (int y = 0; y < 3; ++y) {
for (int x = 0; x < 5; ++x) {
CF_Sprite s = s_tiles[block[y][x]];
CF_V2 local = cf_v2((x - 2) * (float)TILE, (1 - y) * (float)TILE);
s.transform.r = r;
s.transform.p = cf_add_v2(center, cf_mul_sc_v2(r, local));
cf_draw_sprite(&s);
}
}
}

static void s_draw_scene(CF_DrawFilterMode mode, float zoom, float offx, float offy, float angle)
{
cf_draw_push();
cf_draw_scale(zoom, zoom);
cf_draw_translate(offx, offy);
cf_draw_push_filter(mode);
for (int y = 0; y < MAP_H; ++y) {
for (int x = 0; x < MAP_W; ++x) {
TileKind kind = s_map[y][x];
if (kind == TILE_NONE) continue;
CF_Sprite s = s_tiles[kind];
s.transform.p = cf_v2((x - MAP_W / 2) * (float)TILE, (MAP_H / 2 - y) * (float)TILE);
cf_draw_sprite(&s);
}
}
s_draw_rotated_block(angle);
cf_draw_pop_filter();
cf_draw_pop();
}

static const char* s_mode_name(CF_DrawFilterMode mode)
{
switch (mode) {
case CF_DRAW_FILTER_NEAREST: return "NEAREST";
case CF_DRAW_FILTER_LINEAR: return "LINEAR";
case CF_DRAW_FILTER_SMOOTH: return "SMOOTH";
case CF_DRAW_FILTER_PIXELART: return "PIXELART (SDL port)";
default: return "???";
}
}

// Renders the queued draw commands into an offscreen canvas and saves it as a png.
static void s_save_png(const char* path)
{
CF_Canvas canvas = cf_make_canvas(cf_canvas_defaults(CANVAS_W, CANVAS_H));
cf_render_to(canvas, true);
cf_app_draw_onto_screen(false);
CF_Readback rb = cf_canvas_readback(canvas);
while (!cf_readback_ready(rb)) {}
CF_Image img;
img.w = CANVAS_W;
img.h = CANVAS_H;
img.pix = (CF_Pixel*)cf_alloc(CANVAS_W * CANVAS_H * sizeof(CF_Pixel));
cf_readback_data(rb, img.pix, CANVAS_W * CANVAS_H * (int)sizeof(CF_Pixel));
cf_destroy_readback(rb);
cf_destroy_canvas(canvas);
cf_image_save_png(path, &img);
cf_free(img.pix);
printf("saved %s\n", path);
}

int main(int argc, char* argv[])
{
bool screenshot = argc > 1 && CF_STRCMP(argv[1], "screenshot") == 0;
const char* prefix = argc > 2 ? argv[2] : "tile_filters";
float shot_off_px = argc > 3 ? (float)atof(argv[3]) : 0.5f;
float shot_zoom = argc > 4 ? (float)atof(argv[4]) : 3.0f;
float shot_angle = (argc > 5 ? (float)atof(argv[5]) : 22.5f) * (CF_PI / 180.0f);
// Screenshot mode disables HiDPI so one draw unit is exactly one canvas pixel,
// making the half-pixel camera offset land on true half-pixel screen positions.
int options = CF_APP_OPTIONS_WINDOW_POS_CENTERED_BIT | (screenshot ? CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_HIGH_DPI_BIT : CF_APP_OPTIONS_RESIZABLE_BIT);
CF_Result result = cf_make_app("Tile Filters", 0, 0, 0, CANVAS_W, CANVAS_H, options, argv[0]);
if (cf_is_error(result)) return -1;
mount_content_directory_as("/");
cf_fs_set_write_directory(cf_fs_get_base_directory());

CF_Image sheet;
result = cf_image_load_png("/tiles.png", &sheet);
if (cf_is_error(result)) {
printf("Failed to load /tiles.png\n");
return -1;
}
s_tiles[TILE_GRASS_L] = s_cut_tile(&sheet, 0, 0);
s_tiles[TILE_GRASS_M] = s_cut_tile(&sheet, 1, 0);
s_tiles[TILE_GRASS_R] = s_cut_tile(&sheet, 2, 0);
s_tiles[TILE_DIRT] = s_cut_tile(&sheet, 1, 1);
cf_image_free(&sheet);
s_build_map();

CF_DrawFilterMode modes[] = {
CF_DRAW_FILTER_NEAREST,
CF_DRAW_FILTER_LINEAR,
CF_DRAW_FILTER_SMOOTH,
CF_DRAW_FILTER_PIXELART,
};
int mode_count = CF_ARRAY_SIZE(modes);
int mode = 0;
float zoom = 3.0f;
float t = 0;
bool paused = false;
int shot_frame = 0;
int interactive_shot = -1;
float shot_offx = 0, shot_offy = 0;

cf_clear_color(0.35f, 0.60f, 0.95f, 1.0f);

while (cf_app_is_running()) {
cf_app_update(NULL);

if (screenshot) {
// One frame per capture: every mode at a half-pixel offset (worst case for
// seams) plus a zero-offset control. Skip frame 0 so the window settles.
if (shot_frame > 0) {
int idx = shot_frame - 1;
if (idx >= mode_count * 2) break;
int m = idx / 2;
bool half = (idx % 2) == 0;
float off = half ? shot_off_px / shot_zoom : 0;
s_draw_scene(modes[m], shot_zoom, off, off, shot_angle);
char path[256];
snprintf(path, sizeof(path), "%s_%s_%s.png", prefix, s_mode_name(modes[m]), half ? "half" : "zero");
for (char* c = path; *c; ++c) if (*c == ' ' || *c == '(' || *c == ')') *c = '-';
s_save_png(path);
shot_frame++;
continue;
}
shot_frame++;
cf_app_draw_onto_screen(true);
continue;
}

// One capture per frame while an S-key screenshot burst is in flight.
if (interactive_shot >= 0) {
if (interactive_shot < mode_count) {
s_draw_scene(modes[interactive_shot], zoom, shot_offx, shot_offy, t * 0.25f);
char path[256];
snprintf(path, sizeof(path), "%s_%s.png", prefix, s_mode_name(modes[interactive_shot]));
for (char* c = path; *c; ++c) if (*c == ' ' || *c == '(' || *c == ')') *c = '-';
s_save_png(path);
interactive_shot++;
continue;
}
interactive_shot = -1;
}

if (cf_key_just_pressed(CF_KEY_SPACE)) mode = (mode + 1) % mode_count;
if (cf_key_just_pressed(CF_KEY_P)) paused = !paused;
if (cf_key_just_pressed(CF_KEY_UP)) zoom += 0.25f;
if (cf_key_just_pressed(CF_KEY_DOWN)) zoom = cf_max(0.5f, zoom - 0.25f);
if (!paused) t += CF_DELTA_TIME;

// Slow sinusoidal pan; tile edges continuously cross subpixel screen positions.
float offx = sinf(t * 0.30f) * 2.0f;
float offy = cosf(t * 0.23f) * 1.5f;

if (cf_key_just_pressed(CF_KEY_S)) {
interactive_shot = 0;
shot_offx = offx;
shot_offy = offy;
}

s_draw_scene(modes[mode], zoom, offx, offy, t * 0.25f);

cf_draw_push_color(cf_color_white());
cf_push_font_size(20);
char label[256];
snprintf(label, sizeof(label), "Filter: %s -- SPACE cycle, P pause, UP/DOWN zoom (%.2fx), S screenshots", s_mode_name(modes[mode]), zoom);
cf_draw_text(label, cf_v2(-CANVAS_W * 0.5f + 10, CANVAS_H * 0.5f - 10), -1);
cf_pop_font_size();
cf_draw_pop_color();

cf_app_draw_onto_screen(true);
}

cf_destroy_app();
return 0;
}
14 changes: 10 additions & 4 deletions src/cute_draw.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -732,7 +732,7 @@ static void s_draw_report_tiled(const BatchGeometry* geoms, const CF_PendingUV*
cf_material_set_uniform_fs(s_draw->material, "u_texture_size", &u_texture_size, CF_UNIFORM_TYPE_FLOAT2, 1);
int alpha_discard = cmd.alpha_discard == 0.0f ? 0 : 1;
cf_material_set_uniform_fs(s_draw->material, "u_alpha_discard", &alpha_discard, CF_UNIFORM_TYPE_INT, 1);
int use_smooth_uv = cmd.filter_mode == CF_DRAW_FILTER_SMOOTH ? 0 : 1;
int use_smooth_uv = cmd.filter_mode == CF_DRAW_FILTER_SMOOTH ? 0 : (cmd.filter_mode == CF_DRAW_FILTER_PIXELART ? 2 : 1);
cf_material_set_uniform_fs(s_draw->material, "u_use_smooth_uv", &use_smooth_uv, CF_UNIFORM_TYPE_INT, 1);
cf_material_set_render_state(s_draw->material, s_blend_run_state(cmd.render_state, blend, false));
void* sampler_override = (cmd.filter_mode == CF_DRAW_FILTER_NEAREST) ? s_draw->sampler_nearest : s_draw->sampler_linear;
Expand Down Expand Up @@ -832,7 +832,7 @@ static void s_draw_report_tiled(const BatchGeometry* geoms, const CF_PendingUV*
cf_material_set_uniform_fs(s_draw->material, "u_canvas_wh", &u_canvas_wh, CF_UNIFORM_TYPE_FLOAT2, 1);
int alpha_discard = cmd.alpha_discard == 0.0f ? 0 : 1;
cf_material_set_uniform_fs(s_draw->material, "u_alpha_discard", &alpha_discard, CF_UNIFORM_TYPE_INT, 1);
int use_smooth_uv = cmd.filter_mode == CF_DRAW_FILTER_SMOOTH ? 0 : 1;
int use_smooth_uv = cmd.filter_mode == CF_DRAW_FILTER_SMOOTH ? 0 : (cmd.filter_mode == CF_DRAW_FILTER_PIXELART ? 2 : 1);
cf_material_set_uniform_fs(s_draw->material, "u_use_smooth_uv", &use_smooth_uv, CF_UNIFORM_TYPE_INT, 1);
cf_material_set_uniform_fs(s_draw->material, "u_tiles_x", &tiles_x, CF_UNIFORM_TYPE_INT, 1);
int tile_px = CF_TILE_PX;
Expand Down Expand Up @@ -1175,6 +1175,12 @@ void cf_draw_sprite(const CF_Sprite* sprite)
g.shape[2] = quad[2];
g.shape[3] = quad[3];
g.is_sprite = true;
// Sprites don't use the SDF aa lane; it carries the bordered-atlas flag instead so the
// fragment stage can apply PIXELART's seam-safe sampling (clamp away from the
// transparent border ring + binary edge coverage). Any positive value reads as the
// flag, since draw list replays rescale this lane. Premade atlas sprites have no
// border ring.
g.aa = apply_border_scale ? 1.0f : 0.0f;
g.color = premultiply(color_white());
g.alpha = sprite->opacity;
g.user_params = s_draw->user_params.last();
Expand Down Expand Up @@ -4999,8 +5005,8 @@ void static s_blit(CF_Command* cmd, CF_Canvas src, CF_Canvas dst, bool clear_dst
cf_material_set_uniform_fs(s_draw->material, "u_texture_size", &canvas_dims, CF_UNIFORM_TYPE_FLOAT2, 1);
int alpha_discard = cmd->alpha_discard == 0.0f ? 0 : 1;
cf_material_set_uniform_fs(s_draw->material, "u_alpha_discard", &alpha_discard, CF_UNIFORM_TYPE_INT, 1);
// u_use_smooth_uv: 0 = apply shader smooth_uv function, 1 = use plain v_uv (hardware filtering only)
int use_smooth_uv = cmd->filter_mode == CF_DRAW_FILTER_SMOOTH ? 0 : 1;
// u_use_smooth_uv: 0 = apply shader smooth_uv function, 1 = plain v_uv (hardware filtering only), 2 = pixelart_uv
int use_smooth_uv = cmd->filter_mode == CF_DRAW_FILTER_SMOOTH ? 0 : (cmd->filter_mode == CF_DRAW_FILTER_PIXELART ? 2 : 1);
cf_material_set_uniform_fs(s_draw->material, "u_use_smooth_uv", &use_smooth_uv, CF_UNIFORM_TYPE_INT, 1);

// Apply render state.
Expand Down
Loading
Loading