Lighting fixes on transparent textures.

This commit is contained in:
Scott Duensing 2026-09-10 16:00:12 -05:00
parent caac38336c
commit b4376c0cfc
11 changed files with 269 additions and 36 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ docs/.asciidoctor/
/Menu.bat /Menu.bat
/data/ /data/
screenshots/ screenshots/
screenshots-hw/

View file

@ -649,6 +649,13 @@ API Changes
- navAgentNew keeps the radius and height at 0.01 or more, as navNew - navAgentNew keeps the radius and height at 0.01 or more, as navNew
does, so onNavArrived fires for an agent given a radius of zero. does, so onNavArrived fires for an agent given a radius of zero.
- materialSetCutoff(material, cutoff) discards the texels whose base
colour alpha falls below the cutoff, in the lit pass and in the shadow
pass alike, so cutout foliage casts cutout shadows. glTF's MASK alpha
mode now sets it from the file's alphaCutoff instead of warning that
the material would be drawn opaque; BLEND and OPAQUE are unchanged.
Materials that never set a cutoff keep drawing every texel.
- --deterministic[=MS] runs the engine on a virtual clock stepped MS - --deterministic[=MS] runs the engine on a virtual clock stepped MS
milliseconds (default 15) once a frame instead of on real time, steps milliseconds (default 15) once a frame instead of on real time, steps
the disc one video frame a frame with it, and seeds Lua's generator the disc one video frame a frame with it, and seeds Lua's generator

17
INSTALL
View file

@ -59,9 +59,20 @@ library (zlib, zstd, SDL3 and its satellites, OpenSSL, FFmpeg) into
copied to .builddir/Singe-v<version>-<Os>-<arch>. copied to .builddir/Singe-v<version>-<Os>-<arch>.
Host packages (what build-all.sh installs): build-essential cmake Host packages (what build-all.sh installs): build-essential cmake
pkg-config perl nasm llvm imagemagick lua5.4 ffmpeg asciidoctor git-lfs pkg-config perl nasm llvm autoconf automake libtool imagemagick
ruby-asciidoctor-pdf autoconf automake libtool libasound-dev libxi-dev ffmpeg lua5.4 asciidoctor ruby-asciidoctor-pdf libva-dev libvdpau-dev
libvdpau-dev libva-dev libdrm-dev libgl-dev libx11-dev. libdrm-dev libgl-dev libegl-dev libgles-dev libgbm-dev libasound2-dev
libpulse-dev libpipewire-0.3-dev libjack-jackd2-dev libsndio-dev
libudev-dev libdbus-1-dev libibus-1.0-dev libxkbcommon-dev libx11-dev
libxext-dev libxfixes-dev libxi-dev libxcursor-dev libxrandr-dev
libxss-dev libxtst-dev libwayland-dev wayland-protocols libdecor-0-dev.
The artwork, the font and the menu video in assets/ are stored with Git
LFS. Clone with git-lfs present, or those files arrive as small text
pointers and the build embeds the pointers instead of the assets; a
checkout already made without it is repaired with "git lfs install &&
git lfs pull". build-all.sh stops with an error rather than build a
binary around them.
The Linux release build and the Windows build use zig as the compiler: The Linux release build and the Windows build use zig as the compiler:
the build fetches a pinned zig release (version and SHA-256 in the build fetches a pinned zig release (version and SHA-256 in

View file

@ -32,6 +32,66 @@
# ./build-all.sh linux x86_64 --target rebuild-ffmpeg # ./build-all.sh linux x86_64 --target rebuild-ffmpeg
G_BUILDDIR=.builddir G_BUILDDIR=.builddir
G_INSTALL="$(dirname "$0")/INSTALL"
# The one copy of the host package list. Everything else that names these packages derives from it.
# Grouped by what wants them, because the SDL3 half is long and a bare list gives no clue why a
# package is here: SDL3 compiles a backend whenever it finds the headers, so a package missing from
# this list does not fail the build, it quietly drops a backend from the binary.
G_HOSTPACKAGES=(
# Toolchain
build-essential
cmake
git-lfs
pkg-config
perl
nasm
llvm
autoconf
automake
libtool
# Content the build generates: embedded images, the menu video, the LuaSec table, the manual
imagemagick
ffmpeg
lua5.4
asciidoctor
ruby-asciidoctor-pdf
# Video decode and display
libva-dev
libvdpau-dev
libdrm-dev
libgl-dev
libegl-dev
libgles-dev
libgbm-dev
# SDL3 audio backends
libasound2-dev
libpulse-dev
libpipewire-0.3-dev
libjack-jackd2-dev
libsndio-dev
# SDL3 device hotplug, desktop integration and input methods
libudev-dev
libdbus-1-dev
libibus-1.0-dev
libxkbcommon-dev
# SDL3 X11 video backend and the extensions it uses
libx11-dev
libxext-dev
libxfixes-dev
libxi-dev
libxcursor-dev
libxrandr-dev
libxss-dev
libxtst-dev
# SDL3 Wayland video backend
libwayland-dev
wayland-protocols
libdecor-0-dev
)
G_INSTALLLEAD="Host packages (what build-all.sh installs):"
G_INSTALLWIDTH=72
function buildAll() { function buildAll() {
@ -54,37 +114,107 @@ function buildAll() {
} }
# assets/ holds the artwork, the font and the menu video in Git LFS. A clone made without git-lfs
# leaves pointer stubs -- a hundred and thirty bytes of text where a video should be -- and the
# resource generators embed them without complaining, so the failure only shows up at run time as a
# menu with no background. One sentinel is enough to tell the two apart.
function checkoutHydrated() {
local SENTINEL="$(dirname "$0")/assets/180503_01_PurpleGrid.mp4"
# A sentinel that is not there at all is not a hydrated checkout either, and saying so beats
# passing quietly because the path was wrong.
[[ -f "${SENTINEL}" ]] || return 1
! head -c 64 "${SENTINEL}" | grep -q "git-lfs.github.com/spec"
}
# The package list as INSTALL wants to read it: one sentence, wrapped, ending in a full stop.
function installParagraph() {
local LINE="${G_INSTALLLEAD}"
local SEP=" "
local WORD
for WORD in "${G_HOSTPACKAGES[@]}"; do
if (( ${#LINE} + ${#SEP} + ${#WORD} > G_INSTALLWIDTH )); then
printf '%s\n' "${LINE}"
LINE="${WORD}"
else
LINE="${LINE}${SEP}${WORD}"
fi
SEP=" "
done
printf '%s.\n' "${LINE}"
}
# What INSTALL says today: from the lead line to the first line that ends the sentence.
function installCurrent() {
awk -v lead="${G_INSTALLLEAD}" '
index($0, lead) == 1 { found = 1 }
found { print; if (/\.$/) exit }
' "${G_INSTALL}"
}
function installMatches() {
# A checkout without INSTALL is not worth nagging about; there is nothing to disagree with.
[[ ! -f "${G_INSTALL}" ]] || [[ "$(installParagraph)" == "$(installCurrent)" ]]
}
# Writes the paragraph back into INSTALL, replacing whatever sentence is there now.
function installSync() {
local TEMP
TEMP=$(mktemp)
awk -v lead="${G_INSTALLLEAD}" -v replacement="$(installParagraph)" '
index($0, lead) == 1 && !done {
print replacement
skip = 1
}
skip { if (/\.$/) { skip = 0; done = 1 } next }
{ print }
' "${G_INSTALL}" > "${TEMP}"
mv "${TEMP}" "${G_INSTALL}"
}
# -e = stop script on errors # -e = stop script on errors
# -u = stop script on undefined variable # -u = stop script on undefined variable
# -o pipefail = stop pipeline if any step fails # -o pipefail = stop pipeline if any step fails
set -euo pipefail set -euo pipefail
case "${1:-}" in
--packages)
printf '%s\n' "${G_HOSTPACKAGES[@]}"
exit 0
;;
--sync-install)
installSync
echo "INSTALL rewritten from build-all.sh."
exit 0
;;
esac
mkdir -p ${G_BUILDDIR} mkdir -p ${G_BUILDDIR}
# Host packages the build needs on a Debian based system (the same list is in INSTALL). # Host packages the build needs on a Debian based system. INSTALL prints the same list for a
sudo apt-get install -y \ # reader who has not run anything yet, so it is written from this one: --sync-install rewrites
build-essential \ # that paragraph and every build checks it, which is why there is no second list to keep in step.
cmake \ if ! installMatches; then
pkg-config \ echo "warning: INSTALL's host package list no longer matches this script."
perl \ echo " Run ./build-all.sh --sync-install to write it from here."
nasm \ fi
llvm \ sudo apt-get install -y "${G_HOSTPACKAGES[@]}"
imagemagick \
lua5.4 \
ffmpeg \ if ! checkoutHydrated; then
asciidoctor \ echo "error: assets/ holds git-lfs pointer files, not the real artwork, font and video."
ruby-asciidoctor-pdf \ echo " The build would embed those stubs and produce a broken binary."
autoconf \ echo " Fix the checkout with: git lfs install && git lfs pull"
automake \ exit 1
libtool \ fi
libasound-dev \
libxi-dev \
libvdpau-dev \
libva-dev \
libdrm-dev \
libgl-dev \
libx11-dev
if [[ $# -ge 2 ]]; then if [[ $# -ge 2 ]]; then

View file

@ -6,7 +6,7 @@
# Invoked in script mode: -DSHADERCROSS -DSOURCE=<file.hlsl> -DOUTPUT=<header> -DENTRIES=<name:stage;...> # Invoked in script mode: -DSHADERCROSS -DSOURCE=<file.hlsl> -DOUTPUT=<header> -DENTRIES=<name:stage;...>
# -DPREFIX=<lowerCamel prefix> -DTYPE=<struct name>. ENTRIES, PREFIX and TYPE default to the scene's. # -DPREFIX=<lowerCamel prefix> -DTYPE=<struct name>. ENTRIES, PREFIX and TYPE default to the scene's.
if(NOT ENTRIES) if(NOT ENTRIES)
set(ENTRIES vertexStatic:vertex vertexSkinned:vertex fragmentMain:fragment depthMain:fragment particleVertex:vertex particleFragment:fragment lineVertex:vertex lineFragment:fragment postVertex:vertex postFragment:fragment skyFragment:fragment bloomDown:fragment bloomUp:fragment) set(ENTRIES vertexStatic:vertex vertexSkinned:vertex fragmentMain:fragment depthMain:fragment depthCutoutMain:fragment particleVertex:vertex particleFragment:fragment lineVertex:vertex lineFragment:fragment postVertex:vertex postFragment:fragment skyFragment:fragment bloomDown:fragment bloomUp:fragment)
endif() endif()
if(NOT PREFIX) if(NOT PREFIX)
set(PREFIX sceneShader) set(PREFIX sceneShader)

View file

@ -1976,6 +1976,14 @@ Beyond that:
* `materialSetBlend` makes the alpha count, for glass, ghosts and water. * `materialSetBlend` makes the alpha count, for glass, ghosts and water.
Blended meshes draw after everything opaque, sorted by distance, and do Blended meshes draw after everything opaque, sorted by distance, and do
not cast shadows. not cast shadows.
* `materialSetCutoff` keeps only the texels whose alpha reaches the
cutoff and throws the rest away, which is how leaves, fences, grates
and chain link are drawn: one quad, most of it gone. Unlike blending it
needs no sorting and it still casts a shadow, and the shadow is cut out
too, so a tree throws leaf shadows rather than the shadow of its quad.
A cutoff of 0 (the default) turns masking off. A model loaded from glTF
brings its own: `MASK` sets the cutoff from the file, `BLEND` turns
blending on, and `OPAQUE` does neither.
The texture of a material can be any loaded sprite's image The texture of a material can be any loaded sprite's image
(`materialSetTexture`), the laserdisc itself or a loaded video (`materialSetTexture`), the laserdisc itself or a loaded video

View file

@ -854,9 +854,10 @@ static int32_t _loadMaterial(ImageCacheT *cache, const cgltf_material *material)
_applyTexture(handle, cache, &material->occlusion_texture, MAP_OCCLUSION, material->occlusion_texture.scale); _applyTexture(handle, cache, &material->occlusion_texture, MAP_OCCLUSION, material->occlusion_texture.scale);
_applyTexture(handle, cache, &material->emissive_texture, MAP_EMISSIVE, 1.0f); _applyTexture(handle, cache, &material->emissive_texture, MAP_EMISSIVE, 1.0f);
materialSetEmissiveLinear(handle, material->emissive_factor[0] * strength, material->emissive_factor[1] * strength, material->emissive_factor[2] * strength); materialSetEmissiveLinear(handle, material->emissive_factor[0] * strength, material->emissive_factor[1] * strength, material->emissive_factor[2] * strength);
if (material->alpha_mode == cgltf_alpha_mode_mask) { // glTF's three alpha modes: OPAQUE ignores alpha, BLEND sorts and blends, MASK keeps the
_warn("Material %s uses alpha masking (cutoff %.2f), which is drawn opaque.", material->name ? material->name : "(unnamed)", material->alpha_cutoff); // texels at or above a cutoff and discards the rest. A cutoff of zero is masking that keeps
} // everything, so the mode decides whether one is set at all rather than the value.
materialSetCutoff(handle, (material->alpha_mode == cgltf_alpha_mode_mask) ? material->alpha_cutoff : 0.0f);
materialSetBlend(handle, material->alpha_mode == cgltf_alpha_mode_blend); materialSetBlend(handle, material->alpha_mode == cgltf_alpha_mode_blend);
materialSetDoubleSided(handle, material->double_sided); materialSetDoubleSided(handle, material->double_sided);
materialSetUnlit(handle, material->unlit); materialSetUnlit(handle, material->unlit);

View file

@ -65,9 +65,10 @@
#define PIPELINE_SKINNED 1 #define PIPELINE_SKINNED 1
#define PIPELINE_BLEND 2 #define PIPELINE_BLEND 2
#define PIPELINE_TWO_SIDED 4 #define PIPELINE_TWO_SIDED 4
#define SHADOW_PIPELINES 4 // skinned x double sided #define SHADOW_PIPELINES 8 // skinned x double sided x cutout
#define SHADOW_PIPELINE_SKINNED 1 #define SHADOW_PIPELINE_SKINNED 1
#define SHADOW_PIPELINE_TWO_SIDED 2 #define SHADOW_PIPELINE_TWO_SIDED 2
#define SHADOW_PIPELINE_CUTOUT 4
#define SAMPLE_SETS 2 // Pipelines per target sample count ... #define SAMPLE_SETS 2 // Pipelines per target sample count ...
#define SAMPLE_SET_SINGLE 0 // ... single sample (views, and the window without antialiasing) ... #define SAMPLE_SET_SINGLE 0 // ... single sample (views, and the window without antialiasing) ...
#define SAMPLE_SET_MULTI 1 // ... and the window's multisampled targets #define SAMPLE_SET_MULTI 1 // ... and the window's multisampled targets
@ -166,7 +167,7 @@ typedef struct MaterialUniformsS {
float baseColor[4]; float baseColor[4];
float emissive[4]; float emissive[4];
float material[4]; // x = metallic, y = roughness, z = unlit (1/0), w = TEXTURE_* float material[4]; // x = metallic, y = roughness, z = unlit (1/0), w = TEXTURE_*
float maps[4]; // x = normal map strength (0 = none), y = occlusion strength (0 = none) float maps[4]; // x = normal map strength (0 = none), y = occlusion strength (0 = none), z = alpha cutoff (0 = none)
float tiling[4]; // x, y = texture repeats across the surface float tiling[4]; // x, y = texture repeats across the surface
} MaterialUniformsT; } MaterialUniformsT;
@ -225,6 +226,7 @@ typedef struct MaterialS {
Vec3T emissive; Vec3T emissive;
float metallic; float metallic;
float roughness; float roughness;
float cutoff; // glTF alpha masking: texels below this are discarded, 0 = off
SDL_GPUTexture *texture; // Owned; NULL means untextured SDL_GPUTexture *texture; // Owned; NULL means untextured
SDL_GPUTexture *normalMap; // Owned, each NULL when absent SDL_GPUTexture *normalMap; // Owned, each NULL when absent
SDL_GPUTexture *occlusionMap; SDL_GPUTexture *occlusionMap;
@ -448,6 +450,7 @@ typedef struct SceneS {
SDL_GPUShader *vertexSkinned; SDL_GPUShader *vertexSkinned;
SDL_GPUShader *fragment; SDL_GPUShader *fragment;
SDL_GPUShader *depthFragment; // Empty; the shadow pass writes depth only SDL_GPUShader *depthFragment; // Empty; the shadow pass writes depth only
SDL_GPUShader *depthCutoutFragment; // Samples the base texture to mask, for cutout casters
SDL_GPUShader *particleVertex; SDL_GPUShader *particleVertex;
SDL_GPUShader *particleFragment; SDL_GPUShader *particleFragment;
SDL_GPUShader *lineVertex; SDL_GPUShader *lineVertex;
@ -1307,6 +1310,7 @@ static bool _createShaders(void) {
_scene.vertexSkinned = _createShader(&sceneShaderVertexSkinned, SDL_GPU_SHADERSTAGE_VERTEX, 0, 2, 2); _scene.vertexSkinned = _createShader(&sceneShaderVertexSkinned, SDL_GPU_SHADERSTAGE_VERTEX, 0, 2, 2);
_scene.fragment = _createShader(&sceneShaderFragmentMain, SDL_GPU_SHADERSTAGE_FRAGMENT, MATERIAL_SAMPLERS, 2, 0); _scene.fragment = _createShader(&sceneShaderFragmentMain, SDL_GPU_SHADERSTAGE_FRAGMENT, MATERIAL_SAMPLERS, 2, 0);
_scene.depthFragment = _createShader(&sceneShaderDepthMain, SDL_GPU_SHADERSTAGE_FRAGMENT, 0, 0, 0); _scene.depthFragment = _createShader(&sceneShaderDepthMain, SDL_GPU_SHADERSTAGE_FRAGMENT, 0, 0, 0);
_scene.depthCutoutFragment = _createShader(&sceneShaderDepthCutoutMain, SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 2, 0);
_scene.particleVertex = _createShader(&sceneShaderParticleVertex, SDL_GPU_SHADERSTAGE_VERTEX, 0, 1, 0); _scene.particleVertex = _createShader(&sceneShaderParticleVertex, SDL_GPU_SHADERSTAGE_VERTEX, 0, 1, 0);
_scene.particleFragment = _createShader(&sceneShaderParticleFragment, SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 2, 0); _scene.particleFragment = _createShader(&sceneShaderParticleFragment, SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 2, 0);
_scene.lineVertex = _createShader(&sceneShaderLineVertex, SDL_GPU_SHADERSTAGE_VERTEX, 0, 1, 0); _scene.lineVertex = _createShader(&sceneShaderLineVertex, SDL_GPU_SHADERSTAGE_VERTEX, 0, 1, 0);
@ -1316,7 +1320,7 @@ static bool _createShaders(void) {
_scene.postFragment = _createShader(&sceneShaderPostFragment, SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 1, 0); _scene.postFragment = _createShader(&sceneShaderPostFragment, SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 1, 0);
_scene.bloomDownFragment = _createShader(&sceneShaderBloomDown, SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1, 0); _scene.bloomDownFragment = _createShader(&sceneShaderBloomDown, SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1, 0);
_scene.bloomUpFragment = _createShader(&sceneShaderBloomUp, SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 1, 0); _scene.bloomUpFragment = _createShader(&sceneShaderBloomUp, SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 1, 0);
return (_scene.vertexStatic != NULL) && (_scene.vertexSkinned != NULL) && (_scene.fragment != NULL) && (_scene.depthFragment != NULL) && (_scene.particleVertex != NULL) && (_scene.particleFragment != NULL) && (_scene.postVertex != NULL) && (_scene.postFragment != NULL) && (_scene.skyFragment != NULL) && (_scene.bloomDownFragment != NULL) && (_scene.bloomUpFragment != NULL) && (_scene.lineVertex != NULL) && (_scene.lineFragment != NULL); return (_scene.vertexStatic != NULL) && (_scene.vertexSkinned != NULL) && (_scene.fragment != NULL) && (_scene.depthFragment != NULL) && (_scene.depthCutoutFragment != NULL) && (_scene.particleVertex != NULL) && (_scene.particleFragment != NULL) && (_scene.postVertex != NULL) && (_scene.postFragment != NULL) && (_scene.skyFragment != NULL) && (_scene.bloomDownFragment != NULL) && (_scene.bloomUpFragment != NULL) && (_scene.lineVertex != NULL) && (_scene.lineFragment != NULL);
} }
@ -1367,7 +1371,7 @@ static bool _createShadowPipeline(int32_t variant) {
memset(&info, 0, sizeof(info)); memset(&info, 0, sizeof(info));
_describeMeshVertex(&buffer, attributes); _describeMeshVertex(&buffer, attributes);
info.vertex_shader = (variant & SHADOW_PIPELINE_SKINNED) ? _scene.vertexSkinned : _scene.vertexStatic; info.vertex_shader = (variant & SHADOW_PIPELINE_SKINNED) ? _scene.vertexSkinned : _scene.vertexStatic;
info.fragment_shader = _scene.depthFragment; info.fragment_shader = (variant & SHADOW_PIPELINE_CUTOUT) ? _scene.depthCutoutFragment : _scene.depthFragment;
info.vertex_input_state.vertex_buffer_descriptions = &buffer; info.vertex_input_state.vertex_buffer_descriptions = &buffer;
info.vertex_input_state.num_vertex_buffers = 1; info.vertex_input_state.num_vertex_buffers = 1;
info.vertex_input_state.vertex_attributes = attributes; info.vertex_input_state.vertex_attributes = attributes;
@ -1805,7 +1809,7 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i
continue; continue;
} }
// A bulb inside a closed mesh sees only its back faces; they must still cast. // A bulb inside a closed mesh sees only its back faces; they must still cast.
variant = ((variant & PIPELINE_SKINNED) ? SHADOW_PIPELINE_SKINNED : 0) | ((twoSided || (variant & PIPELINE_TWO_SIDED)) ? SHADOW_PIPELINE_TWO_SIDED : 0); variant = ((variant & PIPELINE_SKINNED) ? SHADOW_PIPELINE_SKINNED : 0) | ((twoSided || (variant & PIPELINE_TWO_SIDED)) ? SHADOW_PIPELINE_TWO_SIDED : 0) | ((material->cutoff > 0.0f) ? SHADOW_PIPELINE_CUTOUT : 0);
if ((_scene.shadowPipelines[variant] == NULL) && !_createShadowPipeline(variant)) { if ((_scene.shadowPipelines[variant] == NULL) && !_createShadowPipeline(variant)) {
continue; continue;
} }
@ -1882,6 +1886,28 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i
if (_scene.draws[index].skin != NO_HANDLE) { if (_scene.draws[index].skin != NO_HANDLE) {
SDL_PushGPUVertexUniformData(commands, SKIN_UNIFORMS, &_scene.skins[_scene.draws[index].skin], sizeof(SkinUniformsT)); SDL_PushGPUVertexUniformData(commands, SKIN_UNIFORMS, &_scene.skins[_scene.draws[index].skin], sizeof(SkinUniformsT));
} }
// A cutout caster binds what the masking depth shader reads and nothing else: the base
// texture, the colour whose alpha it multiplies, the cutoff and the tiling. The shader
// declares the frame's buffer it never reads, so a zeroed one goes in to fill the slot.
if (shadowPass && ((variant & SHADOW_PIPELINE_CUTOUT) != 0)) {
SDL_GPUTextureSamplerBinding cutoutBinding;
FragmentUniformsT unread;
memset(&unread, 0, sizeof(unread));
memset(&materialUniforms, 0, sizeof(materialUniforms));
baseTexture = _materialTexture(material);
materialUniforms.baseColor[3] = material->baseColor.w;
materialUniforms.material[3] = (float)((baseTexture == NULL) ? TEXTURE_NONE : TEXTURE_SRGB);
materialUniforms.maps[2] = material->cutoff;
materialUniforms.tiling[0] = material->tilingU;
materialUniforms.tiling[1] = material->tilingV;
memset(&cutoutBinding, 0, sizeof(cutoutBinding));
cutoutBinding.texture = (baseTexture != NULL) ? baseTexture : _scene.white;
cutoutBinding.sampler = (material->filter == FILTER_NEAREST) ? _scene.nearestSampler : _scene.sampler;
SDL_PushGPUFragmentUniformData(commands, FRAME_UNIFORMS, &unread, sizeof(unread));
SDL_PushGPUFragmentUniformData(commands, MATERIAL_UNIFORMS, &materialUniforms, sizeof(materialUniforms));
SDL_BindGPUFragmentSamplers(pass, 0, &cutoutBinding, 1);
}
if (!shadowPass) { if (!shadowPass) {
baseTexture = _materialTexture(material); baseTexture = _materialTexture(material);
memset(&materialUniforms, 0, sizeof(materialUniforms)); memset(&materialUniforms, 0, sizeof(materialUniforms));
@ -1899,6 +1925,7 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i
materialUniforms.material[3] = (float)((baseTexture == NULL) ? TEXTURE_NONE : (((material->feed != NO_HANDLE) || (material->view != NO_HANDLE)) ? TEXTURE_FEED : TEXTURE_SRGB)); materialUniforms.material[3] = (float)((baseTexture == NULL) ? TEXTURE_NONE : (((material->feed != NO_HANDLE) || (material->view != NO_HANDLE)) ? TEXTURE_FEED : TEXTURE_SRGB));
materialUniforms.maps[0] = (material->normalMap != NULL) ? material->normalStrength : 0.0f; materialUniforms.maps[0] = (material->normalMap != NULL) ? material->normalStrength : 0.0f;
materialUniforms.maps[1] = (material->occlusionMap != NULL) ? material->occlusionStrength : 0.0f; materialUniforms.maps[1] = (material->occlusionMap != NULL) ? material->occlusionStrength : 0.0f;
materialUniforms.maps[2] = material->cutoff;
materialUniforms.tiling[0] = material->tilingU; materialUniforms.tiling[0] = material->tilingU;
materialUniforms.tiling[1] = material->tilingV; materialUniforms.tiling[1] = material->tilingV;
SDL_PushGPUFragmentUniformData(commands, MATERIAL_UNIFORMS, &materialUniforms, sizeof(materialUniforms)); SDL_PushGPUFragmentUniformData(commands, MATERIAL_UNIFORMS, &materialUniforms, sizeof(materialUniforms));
@ -3945,6 +3972,18 @@ bool materialSetBlend(int32_t material, bool blend) {
} }
// glTF's alpha masking: a texel whose base colour alpha falls below the cutoff is discarded, in
// the lit pass and in the shadow pass alike. Zero turns masking off, which is the default and what
// every material that never asked for it keeps.
bool materialSetCutoff(int32_t material, float cutoff) {
if (!materialValid(material)) {
return false;
}
_scene.materials[material].cutoff = SDL_clamp(cutoff, 0.0f, 1.0f);
return true;
}
bool materialSetColor(int32_t material, uint8_t r, uint8_t g, uint8_t b, uint8_t a) { bool materialSetColor(int32_t material, uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
if (!materialValid(material)) { if (!materialValid(material)) {
return false; return false;
@ -5634,6 +5673,10 @@ void sceneQuit(void) {
if (_scene.fragment != NULL) { if (_scene.fragment != NULL) {
SDL_ReleaseGPUShader(_scene.device, _scene.fragment); SDL_ReleaseGPUShader(_scene.device, _scene.fragment);
} }
if (_scene.depthCutoutFragment != NULL) {
SDL_ReleaseGPUShader(_scene.device, _scene.depthCutoutFragment);
_scene.depthCutoutFragment = NULL;
}
if (_scene.depthFragment != NULL) { if (_scene.depthFragment != NULL) {
SDL_ReleaseGPUShader(_scene.device, _scene.depthFragment); SDL_ReleaseGPUShader(_scene.device, _scene.depthFragment);
} }

View file

@ -143,6 +143,7 @@ int32_t materialNew(void);
bool materialSetBlend(int32_t material, bool blend); bool materialSetBlend(int32_t material, bool blend);
bool materialSetColor(int32_t material, uint8_t r, uint8_t g, uint8_t b, uint8_t a); bool materialSetColor(int32_t material, uint8_t r, uint8_t g, uint8_t b, uint8_t a);
bool materialSetColorLinear(int32_t material, float r, float g, float b, float a); bool materialSetColorLinear(int32_t material, float r, float g, float b, float a);
bool materialSetCutoff(int32_t material, float cutoff);
bool materialSetDoubleSided(int32_t material, bool doubleSided); bool materialSetDoubleSided(int32_t material, bool doubleSided);
bool materialSetEmissive(int32_t material, uint8_t r, uint8_t g, uint8_t b); bool materialSetEmissive(int32_t material, uint8_t r, uint8_t g, uint8_t b);
bool materialSetEmissiveLinear(int32_t material, float r, float g, float b); bool materialSetEmissiveLinear(int32_t material, float r, float g, float b);

View file

@ -234,7 +234,7 @@ cbuffer MaterialUniforms : register(b1, space3) {
float4 baseColor; float4 baseColor;
float4 emissive; float4 emissive;
float4 material; // x = metallic, y = roughness, z = unlit (1/0), w = TEXTURE_* float4 material; // x = metallic, y = roughness, z = unlit (1/0), w = TEXTURE_*
float4 maps; // x = normal map strength (0 = none), y = occlusion strength (0 = none) float4 maps; // x = normal map strength (0 = none), y = occlusion strength (0 = none), z = alpha cutoff (0 = none)
float4 tiling; // x, y = texture repeats across the surface float4 tiling; // x, y = texture repeats across the surface
}; };
@ -372,6 +372,22 @@ void depthMain(VertexOutput input) {
} }
// The shadow pass for a material with an alpha cutoff. Depth only as well, but the texel has to be
// tested or a leaf casts the shadow of the quad it is drawn on. A video or view feed never masks,
// so the plain sample covers both texture cases; only the alpha is read either way.
void depthCutoutMain(VertexOutput input) {
float4 albedo = baseColor;
float2 uv = input.uv * tiling.xy;
if (material.w > 0.5) {
albedo *= baseTexture.Sample(baseSampler, uv);
}
if ((maps.z > 0.0) && (albedo.a < maps.z)) {
discard;
}
}
// Distance fog toward the fog colour between near and far, when it is on. // Distance fog toward the fog colour between near and far, when it is on.
float3 applyFog(float3 colour, float3 worldPosition) { float3 applyFog(float3 colour, float3 worldPosition) {
float d; float d;
@ -488,6 +504,11 @@ float4 fragmentMain(VertexOutput input) : SV_Target {
} else if (material.w > 0.5) { } else if (material.w > 0.5) {
albedo *= baseTexture.Sample(baseSampler, uv); albedo *= baseTexture.Sample(baseSampler, uv);
} }
// Alpha masking: a glTF MASK material keeps only the texels at or above its cutoff. Tested
// before any lighting, so a discarded texel costs nothing beyond the sample above.
if ((maps.z > 0.0) && (albedo.a < maps.z)) {
discard;
}
if (material.z > 0.5) { if (material.z > 0.5) {
return float4(applyFog(albedo.rgb, input.worldPosition), albedo.a); return float4(applyFog(albedo.rgb, input.worldPosition), albedo.a);
} }

View file

@ -1004,6 +1004,7 @@ static int32_t apiMaterialDelete(lua_State *L);
static int32_t apiMaterialNew(lua_State *L); static int32_t apiMaterialNew(lua_State *L);
static int32_t apiMaterialSetBlend(lua_State *L); static int32_t apiMaterialSetBlend(lua_State *L);
static int32_t apiMaterialSetColor(lua_State *L); static int32_t apiMaterialSetColor(lua_State *L);
static int32_t apiMaterialSetCutoff(lua_State *L);
static int32_t apiMaterialSetDoubleSided(lua_State *L); static int32_t apiMaterialSetDoubleSided(lua_State *L);
static int32_t apiMaterialSetEmissive(lua_State *L); static int32_t apiMaterialSetEmissive(lua_State *L);
static int32_t apiMaterialSetEmissiveMap(lua_State *L); static int32_t apiMaterialSetEmissiveMap(lua_State *L);
@ -4999,6 +5000,7 @@ static void _registerApi(lua_State *L) {
lua_register(L, "materialNew", apiMaterialNew); // 3.00 lua_register(L, "materialNew", apiMaterialNew); // 3.00
lua_register(L, "materialSetBlend", apiMaterialSetBlend); // 3.00 lua_register(L, "materialSetBlend", apiMaterialSetBlend); // 3.00
lua_register(L, "materialSetColor", apiMaterialSetColor); // 3.00 lua_register(L, "materialSetColor", apiMaterialSetColor); // 3.00
lua_register(L, "materialSetCutoff", apiMaterialSetCutoff); // 3.00
lua_register(L, "materialSetDoubleSided", apiMaterialSetDoubleSided); // 3.00 lua_register(L, "materialSetDoubleSided", apiMaterialSetDoubleSided); // 3.00
lua_register(L, "materialSetEmissive", apiMaterialSetEmissive); // 3.00 lua_register(L, "materialSetEmissive", apiMaterialSetEmissive); // 3.00
lua_register(L, "materialSetEmissiveMap", apiMaterialSetEmissiveMap); // 3.00 lua_register(L, "materialSetEmissiveMap", apiMaterialSetEmissiveMap); // 3.00
@ -8868,6 +8870,14 @@ static int32_t apiMaterialSetColor(lua_State *L) {
} }
// materialSetCutoff(material, cutoff)
static int32_t apiMaterialSetCutoff(lua_State *L) {
_argCheck(L, "materialSetCutoff", 2, 2);
materialSetCutoff(_argMaterial(L, "materialSetCutoff", 1), (float)_argNumber(L, "materialSetCutoff", 2));
return 0;
}
// materialSetDoubleSided(material, bool) // materialSetDoubleSided(material, bool)
static int32_t apiMaterialSetDoubleSided(lua_State *L) { static int32_t apiMaterialSetDoubleSided(lua_State *L) {
_argCheck(L, "materialSetDoubleSided", 2, 2); _argCheck(L, "materialSetDoubleSided", 2, 2);