From b4376c0cfc0d16f97268a97449fe422084b9a5e1 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Thu, 10 Sep 2026 16:00:12 -0500 Subject: [PATCH] Lighting fixes on transparent textures. --- .gitignore | 1 + CHANGELOG | 7 ++ INSTALL | 17 +++- build-all.sh | 176 ++++++++++++++++++++++++++++++++++----- cmake/shaderHeader.cmake | 2 +- docs/Manual.adoc | 8 ++ src/model.c | 7 +- src/scene.c | 53 ++++++++++-- src/scene.h | 1 + src/shaders/scene.hlsl | 23 ++++- src/singe.c | 10 +++ 11 files changed, 269 insertions(+), 36 deletions(-) diff --git a/.gitignore b/.gitignore index eeb466b4a..010770d68 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ docs/.asciidoctor/ /Menu.bat /data/ screenshots/ +screenshots-hw/ diff --git a/CHANGELOG b/CHANGELOG index a8a8c3974..18233cd43 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -649,6 +649,13 @@ API Changes - 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. +- 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 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 diff --git a/INSTALL b/INSTALL index e8fbeb934..7e24c5e60 100644 --- a/INSTALL +++ b/INSTALL @@ -59,9 +59,20 @@ library (zlib, zstd, SDL3 and its satellites, OpenSSL, FFmpeg) into copied to .builddir/Singe-v--. Host packages (what build-all.sh installs): build-essential cmake -pkg-config perl nasm llvm imagemagick lua5.4 ffmpeg asciidoctor -ruby-asciidoctor-pdf autoconf automake libtool libasound-dev libxi-dev -libvdpau-dev libva-dev libdrm-dev libgl-dev libx11-dev. +git-lfs pkg-config perl nasm llvm autoconf automake libtool imagemagick +ffmpeg lua5.4 asciidoctor ruby-asciidoctor-pdf libva-dev libvdpau-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 build fetches a pinned zig release (version and SHA-256 in diff --git a/build-all.sh b/build-all.sh index ab1fdf23a..effaa9f0b 100755 --- a/build-all.sh +++ b/build-all.sh @@ -32,6 +32,66 @@ # ./build-all.sh linux x86_64 --target rebuild-ffmpeg 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() { @@ -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 # -u = stop script on undefined variable # -o pipefail = stop pipeline if any step fails 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} -# Host packages the build needs on a Debian based system (the same list is in INSTALL). -sudo apt-get install -y \ - build-essential \ - cmake \ - pkg-config \ - perl \ - nasm \ - llvm \ - imagemagick \ - lua5.4 \ - ffmpeg \ - asciidoctor \ - ruby-asciidoctor-pdf \ - autoconf \ - automake \ - libtool \ - libasound-dev \ - libxi-dev \ - libvdpau-dev \ - libva-dev \ - libdrm-dev \ - libgl-dev \ - libx11-dev +# Host packages the build needs on a Debian based system. INSTALL prints the same list for a +# reader who has not run anything yet, so it is written from this one: --sync-install rewrites +# that paragraph and every build checks it, which is why there is no second list to keep in step. +if ! installMatches; then + echo "warning: INSTALL's host package list no longer matches this script." + echo " Run ./build-all.sh --sync-install to write it from here." +fi +sudo apt-get install -y "${G_HOSTPACKAGES[@]}" + + +if ! checkoutHydrated; then + echo "error: assets/ holds git-lfs pointer files, not the real artwork, font and video." + echo " The build would embed those stubs and produce a broken binary." + echo " Fix the checkout with: git lfs install && git lfs pull" + exit 1 +fi if [[ $# -ge 2 ]]; then diff --git a/cmake/shaderHeader.cmake b/cmake/shaderHeader.cmake index 496cabb41..54df094ad 100644 --- a/cmake/shaderHeader.cmake +++ b/cmake/shaderHeader.cmake @@ -6,7 +6,7 @@ # Invoked in script mode: -DSHADERCROSS -DSOURCE= -DOUTPUT=
-DENTRIES= # -DPREFIX= -DTYPE=. ENTRIES, PREFIX and TYPE default to the scene's. 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() if(NOT PREFIX) set(PREFIX sceneShader) diff --git a/docs/Manual.adoc b/docs/Manual.adoc index f5eac8497..da619640a 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -1976,6 +1976,14 @@ Beyond that: * `materialSetBlend` makes the alpha count, for glass, ghosts and water. Blended meshes draw after everything opaque, sorted by distance, and do 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 (`materialSetTexture`), the laserdisc itself or a loaded video diff --git a/src/model.c b/src/model.c index 58f931174..272cf3226 100644 --- a/src/model.c +++ b/src/model.c @@ -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->emissive_texture, MAP_EMISSIVE, 1.0f); 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) { - _warn("Material %s uses alpha masking (cutoff %.2f), which is drawn opaque.", material->name ? material->name : "(unnamed)", material->alpha_cutoff); - } + // glTF's three alpha modes: OPAQUE ignores alpha, BLEND sorts and blends, MASK keeps the + // 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); materialSetDoubleSided(handle, material->double_sided); materialSetUnlit(handle, material->unlit); diff --git a/src/scene.c b/src/scene.c index 457c03b2b..3f60f0b4d 100644 --- a/src/scene.c +++ b/src/scene.c @@ -65,9 +65,10 @@ #define PIPELINE_SKINNED 1 #define PIPELINE_BLEND 2 #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_TWO_SIDED 2 +#define SHADOW_PIPELINE_CUTOUT 4 #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_MULTI 1 // ... and the window's multisampled targets @@ -166,7 +167,7 @@ typedef struct MaterialUniformsS { float baseColor[4]; float emissive[4]; 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 } MaterialUniformsT; @@ -225,6 +226,7 @@ typedef struct MaterialS { Vec3T emissive; float metallic; float roughness; + float cutoff; // glTF alpha masking: texels below this are discarded, 0 = off SDL_GPUTexture *texture; // Owned; NULL means untextured SDL_GPUTexture *normalMap; // Owned, each NULL when absent SDL_GPUTexture *occlusionMap; @@ -448,6 +450,7 @@ typedef struct SceneS { SDL_GPUShader *vertexSkinned; SDL_GPUShader *fragment; 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 *particleFragment; SDL_GPUShader *lineVertex; @@ -1307,6 +1310,7 @@ static bool _createShaders(void) { _scene.vertexSkinned = _createShader(&sceneShaderVertexSkinned, SDL_GPU_SHADERSTAGE_VERTEX, 0, 2, 2); _scene.fragment = _createShader(&sceneShaderFragmentMain, SDL_GPU_SHADERSTAGE_FRAGMENT, MATERIAL_SAMPLERS, 2, 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.particleFragment = _createShader(&sceneShaderParticleFragment, SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 2, 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.bloomDownFragment = _createShader(&sceneShaderBloomDown, SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 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)); _describeMeshVertex(&buffer, attributes); 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.num_vertex_buffers = 1; info.vertex_input_state.vertex_attributes = attributes; @@ -1805,7 +1809,7 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i continue; } // 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)) { continue; } @@ -1882,6 +1886,28 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i if (_scene.draws[index].skin != NO_HANDLE) { 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) { baseTexture = _materialTexture(material); 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.maps[0] = (material->normalMap != NULL) ? material->normalStrength : 0.0f; materialUniforms.maps[1] = (material->occlusionMap != NULL) ? material->occlusionStrength : 0.0f; + materialUniforms.maps[2] = material->cutoff; materialUniforms.tiling[0] = material->tilingU; materialUniforms.tiling[1] = material->tilingV; 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) { if (!materialValid(material)) { return false; @@ -5634,6 +5673,10 @@ void sceneQuit(void) { if (_scene.fragment != NULL) { SDL_ReleaseGPUShader(_scene.device, _scene.fragment); } + if (_scene.depthCutoutFragment != NULL) { + SDL_ReleaseGPUShader(_scene.device, _scene.depthCutoutFragment); + _scene.depthCutoutFragment = NULL; + } if (_scene.depthFragment != NULL) { SDL_ReleaseGPUShader(_scene.device, _scene.depthFragment); } diff --git a/src/scene.h b/src/scene.h index 2543fa050..b8b858e28 100644 --- a/src/scene.h +++ b/src/scene.h @@ -143,6 +143,7 @@ int32_t materialNew(void); 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 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 materialSetEmissive(int32_t material, uint8_t r, uint8_t g, uint8_t b); bool materialSetEmissiveLinear(int32_t material, float r, float g, float b); diff --git a/src/shaders/scene.hlsl b/src/shaders/scene.hlsl index 8c5d75af7..1e0f55456 100644 --- a/src/shaders/scene.hlsl +++ b/src/shaders/scene.hlsl @@ -234,7 +234,7 @@ cbuffer MaterialUniforms : register(b1, space3) { float4 baseColor; float4 emissive; 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 }; @@ -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. float3 applyFog(float3 colour, float3 worldPosition) { float d; @@ -488,6 +504,11 @@ float4 fragmentMain(VertexOutput input) : SV_Target { } else if (material.w > 0.5) { 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) { return float4(applyFog(albedo.rgb, input.worldPosition), albedo.a); } diff --git a/src/singe.c b/src/singe.c index cfd61fc22..06c86c591 100644 --- a/src/singe.c +++ b/src/singe.c @@ -1004,6 +1004,7 @@ static int32_t apiMaterialDelete(lua_State *L); static int32_t apiMaterialNew(lua_State *L); static int32_t apiMaterialSetBlend(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 apiMaterialSetEmissive(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, "materialSetBlend", apiMaterialSetBlend); // 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, "materialSetEmissive", apiMaterialSetEmissive); // 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) static int32_t apiMaterialSetDoubleSided(lua_State *L) { _argCheck(L, "materialSetDoubleSided", 2, 2);