diff --git a/packages/ludic.render3d/collide.ludic b/packages/ludic.render3d/collide.ludic index d12da01b..c4febce2 100644 --- a/packages/ludic.render3d/collide.ludic +++ b/packages/ludic.render3d/collide.ludic @@ -12,6 +12,12 @@ const COL_CAP: int = 120000 var col_x: words = null var col_z: words = null var col_r: words = null +# What a collider occupies VERTICALLY: y0 its base, y1 its top, metres, float bits. A circle used +# to be an infinite pillar - you could not climb a boulder, and a knee-high rock stopped you dead, +# because there was no height to compare against. col_add keeps that shape (a span from far below +# to far above) so every existing caller behaves exactly as it did; col_add_h gives a real one. +var col_y0: words = null +var col_y1: words = null var col_n: int = 0 var col_side: int = 0 # cells per side var col_start: words = null # per cell: first index into col_sorted (side*side + 1) @@ -19,10 +25,19 @@ var col_sorted: words = null var col_built: bool = false var col_out: words = null # the resolved position (x, z) -function col_add(x: int, z: int, r: int) -> void { - if col_x == null { col_x = words(COL_CAP); col_z = words(COL_CAP); col_r = words(COL_CAP); col_out = words(2) } +const COL_LOW: int = 0xCB800000 # -16777216.0: below any ground +const COL_HIGH: int = 0x4B800000 # 16777216.0: above any sky +function col_add(x: int, z: int, r: int) -> void { col_add_h(x, z, r, COL_LOW, COL_HIGH) } +# a collider that occupies only y0 .. y1: a body above its top walks over it, a body below its base +# passes under, and col_top_at reports it as something to stand on +function col_add_h(x: int, z: int, r: int, y0: int, y1: int) -> void { + if col_x == null { + col_x = words(COL_CAP); col_z = words(COL_CAP); col_r = words(COL_CAP) + col_y0 = words(COL_CAP); col_y1 = words(COL_CAP); col_out = words(2) + } if col_n >= COL_CAP { return } col_x[col_n] = x; col_z[col_n] = z; col_r[col_n] = r + col_y0[col_n] = y0; col_y1[col_n] = y1 col_n += 1 col_built = false } @@ -52,7 +67,10 @@ function col_build() -> void { } # push (px, pz) with radius pr out of every circle it overlaps; the result is in col_out -function col_resolve(px: int, pz: int, pr: int) -> bool { +function col_resolve(px: int, pz: int, pr: int) -> bool { return col_resolve_at(px, pz, pr, COL_LOW, COL_HIGH) } +# the same, for a body that occupies feet .. head: a collider whose span misses that is not in the +# way at all. This is what lets a hiker stand on top of a boulder rather than inside it. +function col_resolve_at(px: int, pz: int, pr: int, feet: int, head: int) -> bool { col_out[0] = px; col_out[1] = pz if not col_built or col_n == 0 { return false } var x = px; var z = pz @@ -71,6 +89,9 @@ function col_resolve(px: int, pz: int, pr: int) -> bool { let ex = f_sub(x, col_x[i]); let ez = f_sub(z, col_z[i]) let d2 = f_add(f_mul(ex, ex), f_mul(ez, ez)) let rr = f_add(col_r[i], pr) + # nothing to push out of if the body is wholly above its top or below its base + if not f_ls(feet, col_y1[i]) { continue } + if not f_gt(head, col_y0[i]) { continue } if f_ls(d2, f_mul(rr, rr)) { let d = f_sqrt(d2) # The UNIT normal out of this circle. (ex, ez) / d is always unit for d > 0, @@ -98,6 +119,35 @@ function col_resolve(px: int, pz: int, pr: int) -> bool { col_out[0] = x; col_out[1] = z return moved } +# The highest collider top under (px, pz) that a body at `feet` could be standing on or step up to: +# tops above `reach` are a wall, not a step. F_ZERO-safe: returns `floor` when there is nothing, so +# a caller can pass the terrain height and use the answer directly as the ground. +function col_top_at(px: int, pz: int, pr: int, feet: int, reach: int, floor: int) -> int { + var top = floor + if not col_built or col_n == 0 { return top } + let cx = col_cell_of(px, ter_ox); let cz = col_cell_of(pz, ter_oz) + let limit = f_add(feet, reach) + for dz in 0 .. 3 { + let zc = cz + dz - 1 + if zc < 0 or zc >= col_side { continue } + for dx in 0 .. 3 { + let xc = cx + dx - 1 + if xc < 0 or xc >= col_side { continue } + let c = zc * col_side + xc + for k in col_start[c] .. col_start[c + 1] { + let i = col_sorted[k] + let ex = f_sub(px, col_x[i]); let ez = f_sub(pz, col_z[i]) + let d2 = f_add(f_mul(ex, ex), f_mul(ez, ez)) + let rr = f_add(col_r[i], pr) + if not f_ls(d2, f_mul(rr, rr)) { continue } + let t = col_y1[i] + if f_gt(t, limit) { continue } # too tall to step onto: it is a wall + if f_gt(t, top) { top = t } + } + } + } + return top +} # is the segment from (x0,z0) to (x1,z1) clear of every circle (a camera line of sight)? function col_clear(x0: int, z0: int, x1: int, z1: int, r: int) -> bool { let steps = 6 diff --git a/packages/ludic.render3d/gpu_vk_res.ludic b/packages/ludic.render3d/gpu_vk_res.ludic index 09852920..ab3b4139 100644 --- a/packages/ludic.render3d/gpu_vk_res.ludic +++ b/packages/ludic.render3d/gpu_vk_res.ludic @@ -470,21 +470,45 @@ function gvk_compare_op(f: int) -> int { if f == GL_ALWAYS { return VK_COMPARE_OP_ALWAYS } return VK_COMPARE_OP_LESS_OR_EQUAL } +# The texture mip bias DLSS needs. DLSS draws the scene at a fraction of the output resolution, so +# every texture picks its mip for THAT resolution - and then the upscaler has no detail left to +# reconstruct, which is what "blurry and muddy" is. NVIDIA's requirement is to bias the mip +# selection back toward the output resolution: +# +# bias = log2(renderWidth / displayWidth) - 1 +# +# which is -2.0 at Performance and about -1.6 at Quality. It is applied ONLY while DLSS is live: a +# plain spatial upscale has no temporal accumulation to hide the aliasing a negative bias brings, +# so biasing there would trade blur for shimmer. +function gvk_mip_bias() -> int { + # R3D_NO_MIPBIAS=1 puts it back the way it was, so one build can be compared against itself + if r3d_env_has("R3D_NO_MIPBIAS") { return F_ZERO } + if not r3d_dlss_live() { return F_ZERO } + let rw = r3d_dlss_render_w() + if rw <= 0 or gl_w <= 0 or rw >= gl_w { return F_ZERO } + # log2 from the natural log the runtime has: log2(x) = ln(x) * 1/ln(2) + return f_sub(f_mul(f_log(f_div(fi(rw), fi(gl_w))), fl(1.4426950408889634)), F_ONE) +} # the sampler for texture tex's parameters, from the texture's own one-entry cache when they have # not changed since it last asked - a draw asks for every texture it binds function gvk_tex_sampler(tex: int, min_f: int, mag_f: int, wrap_s: int, wrap_t: int, compare: int, aniso: int) -> long { + let bias = gvk_mip_bias() var sig = min_f * 31 + mag_f sig = sig * 31 + wrap_s sig = sig * 31 + wrap_t sig = sig * 31 + compare - sig = (sig * 31 + aniso) | 1 + sig = sig * 31 + aniso + # the bias is part of what the sampler IS, so it has to invalidate this cache too - otherwise + # turning DLSS on mid-session keeps every sampler already made at the old bias + sig = (sig * 31 + bias) | 1 if tex > 0 and tex < len(gvk_tex_smp_sig) and gvk_tex_smp_sig[tex] == sig { return gvk_tex_smp[tex] } let s = gvk_sampler(min_f, mag_f, wrap_s, wrap_t, compare, aniso) if tex > 0 and tex < len(gvk_tex_smp_sig) { gvk_tex_smp_sig[tex] = sig; gvk_tex_smp[tex] = s } return s } function gvk_sampler(min_f: int, mag_f: int, wrap_s: int, wrap_t: int, compare: int, aniso: int) -> long { - let key = `{min_f}/{mag_f}/{wrap_s}/{wrap_t}/{compare}/{aniso}` + let bias = gvk_mip_bias() + let key = `{min_f}/{mag_f}/{wrap_s}/{wrap_t}/{compare}/{aniso}/{bias}` if gvk_smp_keys == null { gvk_smp_keys = new []string; gvk_smp = new []long } for i in 0 .. len(gvk_smp_keys) { if gvk_smp_keys[i] == key { return gvk_smp[i] } } var mn = min_f @@ -504,6 +528,7 @@ function gvk_sampler(min_f: int, mag_f: int, wrap_s: int, wrap_t: int, compare: Vk.put_i32(sci, VkSamplerCreateInfo_addressModeW, gvk_address(wrap_t)) # without mipmaps, a max LOD of 0.25 samples level 0 only (the spec's own recipe for GL_LINEAR) if mipmapped { Vk.put_i32(sci, VkSamplerCreateInfo_maxLod, 0x447A0000) } else { Vk.put_i32(sci, VkSamplerCreateInfo_maxLod, 0x3E800000) } + if mipmapped { Vk.put_i32(sci, VkSamplerCreateInfo_mipLodBias, bias) } if aniso > 0x3F800000 and mipmapped { var a = aniso if a > gvk_max_aniso { a = gvk_max_aniso } diff --git a/packages/ludic.render3d/grass.ludic b/packages/ludic.render3d/grass.ludic index 3828670c..94501910 100644 --- a/packages/ludic.render3d/grass.ludic +++ b/packages/ludic.render3d/grass.ludic @@ -15,6 +15,14 @@ var grass_prog: int = 0 var grass_mesh: Mesh = null var grass_on: bool = true var grass_wind: int = 0 +# A photographed blade, as an atlas of straightened blades side by side (the game sets +# this; the renderer does not name a game asset). 0 = the procedural gradient, which is +# what this was for a year: a two-tone ramp with a hard edge, and every blade in the +# valley the same blade. A real blade has a midrib, a colour that runs olive to straw, +# browning where it has dried and a tip that is its own shape - none of which can be +# written down, only photographed. +var grass_blade_tex: int = 0 +var grass_blade_cols: int = 8 # Where a body is standing, and how wide it pushes. The grass has never known the player was # in it: you walked through a meadow and every blade ignored you, which is the single most # noticeable thing missing from every step the game asks you to take. The game sets this each @@ -59,10 +67,25 @@ function grass_blade_mesh(rows: int) -> Mesh { let m = gpu_mesh_new() let v = gl_floats(rows * 2 * 5) var k = 0 + # The blade's PROFILE, and it is the whole difference between grass and a green spike. + # It used to be `1 - t^2.5` floored at 0.12 with a bend of 0.28t^2: widest at the very + # bottom, narrowing to a needle, and standing almost straight. That is the silhouette of + # a pine needle, and eighty of them to the square metre read as a bed of nails. + # + # A real blade is narrow where it leaves the sheath, WIDEST about a fifth of the way up, + # and then tapers the rest of the way to a fine point - and it arches over under its own + # weight. Both terms below say that. The tip is floored just off zero rather than at 0.12 + # so the point is a point and not a cut-off stub, but not so low that the last quad is + # degenerate. + # + # grass.mesh carries A COPY of these two lines for the mesh-shader path; change both or + # the Windows blades stop matching the ones everywhere else. for r in 0 .. rows { let t = fr(r, rows - 1) - let taper = f_max(f_sub(F_ONE, f_mul(t, f_mul(t, f_sqrt(t)))), fl(0.12)) - let bend = f_mul(f_mul(t, t), fl(0.28)) + let grow = f_min(f_div(t, fl(0.22)), F_ONE) + let wide = f_mul(f_add(fl(0.50), f_mul(fl(0.33), grow)), f_sub(F_ONE, f_mul(t, f_mul(t, t)))) + let taper = f_max(wide, fl(0.05)) + let bend = f_mul(f_mul(t, t), fl(0.52)) for sd in 0 .. 2 { var x = f_neg(F_HALF) if sd == 1 { x = F_HALF } @@ -100,9 +123,17 @@ function grass_init() -> void { } grass_prog = r3d_program("grass.vert", "model.frag", defs) if grass_merge and gpu_has_mesh() { grass_mesh_prog = r3d_program("grass.mesh", "model.frag", "#define FOLIAGE\n#define BLADE\n#define MESH\n") } - grass_mesh = grass_blade_mesh(4) + # five rows, four quads: the arch above needs somewhere to bend, and at four rows a + # blade that leans over is three straight segments and shows every join + grass_mesh = grass_blade_mesh(5) grass_wind = fl(2.4) - grass_s0 = fl(0.11) + # Matched to the blade's width: a 1 cm blade at 0.11 m spacing covers a third of what a + # 2.8 cm blade did, and the meadow goes bare. The game's graphics settings override this + # (gfx_grass_spacing), but only once game_init has run - a plain headless render never + # gets there, so the two have to agree or a shot shows something no player will see. + # That is exactly how the last change measured as "no effect": the render was identical + # because this line, not the settings, was deciding. + grass_s0 = fl(0.066) grass_d0 = fi(45) grass_radius = fi(1600) if r3d_env_has("R3D_NOBLADES") { grass_on = false } @@ -181,6 +212,14 @@ function grass_draw() -> void { u_mat4(gpu_uniform(p, "u_proj"), cam_proj) u_mat4(gpu_uniform(p, "u_vp"), cam_vp_clean) u_f(gpu_uniform(p, "u_wind"), grass_wind) + # copied into a local first: a global reaching a uniform call is the codegen fault + # CLAUDE.md records against u_wade and u_flutter, and it costs a day every time + let btex = grass_blade_tex + let bcols = grass_blade_cols + u_f(gpu_uniform(p, "u_blade_cols"), fi(bcols)) + var bon = F_ZERO + if btex != 0 { bon = F_ONE; r3d_bind_2d(p, "u_blade_tex", 12, btex) } + u_f(gpu_uniform(p, "u_blade_tex_on"), bon) u_f3(gpu_uniform(p, "u_push"), grass_push_x, grass_push_z, grass_push_r) u_f(gpu_uniform(p, "u_rough_scale"), F_ONE) u_v3(gpu_uniform(p, "u_tint"), sc_blade_tint) @@ -208,7 +247,12 @@ function grass_draw() -> void { sky_bind_lighting(p) shadow_bind(p) fog_bind(p) - u_f(gpu_uniform(p, "u_spec_scale"), fl(0.15)) + # 0.15 was enough to put a hard white highlight down the length of a blade whenever it + # caught the sun, and a white blade of grass is the one thing grass is never. Measured: + # at 0.15, 0.70% of a near-ground frame was over 210 of 255; at 0.0 it is 0.05%. A blade + # does have a faint sheen, so this is small rather than nothing - the foliage layers have + # used 0.05 all along and never showed the fault. + u_f(gpu_uniform(p, "u_spec_scale"), fl(0.008)) gpu_cull(false) grass_draws = 0 grass_n = 0 diff --git a/packages/ludic.render3d/shaders/grass.mesh b/packages/ludic.render3d/shaders/grass.mesh index b5210fbd..60f50ce0 100644 --- a/packages/ludic.render3d/shaders/grass.mesh +++ b/packages/ludic.render3d/shaders/grass.mesh @@ -8,7 +8,7 @@ // count is skipped. layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in; const int BLADES = 16; // blades an invocation may emit -const int ROWS = 4; // grass_blade_mesh(4): two vertices a row, three quads +const int ROWS = 5; // grass_blade_mesh(5): two vertices a row, four quads layout(triangles, max_vertices = 128, max_primitives = 96) out; uniform mat4 u_view; @@ -129,11 +129,13 @@ void main() { if (tilt) n = normalize(n * ck + cross(k, n) * sk + k * dot(k, n) * (1.0 - ck)); n = normalize(mix(n, gn, smoothstep(2.0, 12.0, dist))); float hull = (dist > 2.0 || far || (u_dbg & 2) != 0) ? -1.0 : 1.0; - // the blade: grass_blade_mesh(4)'s vertices, placed as grass.vert places them + // the blade: grass_blade_mesh(5)'s vertices, placed as grass.vert places them for (int r = 0; r < ROWS; r++) { float t = float(r) / float(ROWS - 1); - float taper = max(1.0 - t * t * sqrt(t), 0.12); - float bend = t * t * 0.28; + // A COPY of grass_blade_mesh's profile in grass.ludic - narrow at the sheath, + // widest a fifth of the way up, a fine point, and arched. Change both together. + float taper = max((0.50 + 0.33 * min(t / 0.22, 1.0)) * (1.0 - t * t * t), 0.05); + float bend = t * t * 0.52; for (int sd = 0; sd < 2; sd++) { vec3 a_pos = vec3((float(sd) - 0.5) * taper, t, bend); vec2 a_uv = vec2(float(sd), t); diff --git a/packages/ludic.render3d/shaders/grass.vert b/packages/ludic.render3d/shaders/grass.vert index e2f27f0e..03475942 100644 --- a/packages/ludic.render3d/shaders/grass.vert +++ b/packages/ludic.render3d/shaders/grass.vert @@ -100,8 +100,16 @@ void main() { // of the count shrinks to nothing so a blade never pops. float spacing = u_s0 * (1.0 + dist / u_d0); float count = CELL * CELL / (spacing * spacing) * (1.0 - smoothstep(u_radius * 0.7, u_radius, dist)); - if (fj >= count) { cull(); return; } - float life = 1.0 - smoothstep(0.8, 1.0, fj / max(count, 1.0)); + // WHICH blades thin out has to be random, not the last indices. `fj >= count` keeps + // blades 0..N-1, and as N falls by one with distance, the SAME index dies in every + // cell at the same radius - one blade in a fixed place per cell, over a whole ring. + // Seen from above that is a set of arcs centred on the camera, and at eye level it is + // the banding that reads as a ploughed field. Giving each blade its own fixed number + // and comparing that against the density makes the thinning scatter instead. + float keep = count / float(max(per_cell, 1)); + float r = bladeHash(ci, j, 5); + if (r > keep) { cull(); return; } + float life = 1.0 - smoothstep(keep * 0.75, keep, r); // the ground under it vec2 huv = (xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5; if (huv.x < 0.0 || huv.x > 1.0 || huv.y < 0.0 || huv.y > 1.0) { cull(); return; } @@ -136,7 +144,11 @@ void main() { if ((u_dbg & 1) != 0) h += 0.3; // the blade: sized so that coverage stays level as the spacing grows float seed = hv.x * 0.7 + hv.y * 0.3; - float ang = hv.y * 6.2831853; + // A BLADE'S YAW MUST NOT BE ITS POSITION. This was `hv.y * 2pi`, and hv.y is the same + // number that places the blade along the cell's z - so every blade at the same depth + // in a cell faced the same way, in rows, sixteen metres wide. That is the single thing + // that made the meadow look ploughed. Its own hash costs nothing. + float ang = bladeHash(ci, j, 6) * 6.2831853; float s = sin(ang), c_ = cos(ang); float grow = spacing / u_s0; // 1 at the camera, growing with distance // Height is biased SHORT rather than spread evenly. A meadow is not one length of grass: it @@ -146,7 +158,12 @@ void main() { // that had been cut, which is the one thing an alpine meadow is not. float hh = h3 * h3; float tall = mix(0.09, 0.60, hh) * mix(0.8, 1.2, hash1(cid * 0.1)) * (1.0 + 0.35 * smoothstep(1.0, 12.0, grow)) * life; - float bw = 0.028 * mix(1.0, 0.45 * grow, smoothstep(1.0, 4.0, grow)); + // 2.8 cm was FIVE TIMES a blade of meadow grass, which is 3-6 mm. At 2 m from the + // camera that is a broad dark scimitar lying along the ground rather than a blade + // standing in a sward, and no amount of profile or colour work fixes a blade that is + // the wrong size. Measured, not guessed: it is the one number behind every "too wide + // and too flat" note in this stage. + float bw = 0.010 * mix(1.0, 0.45 * grow, smoothstep(1.0, 4.0, grow)); if (far) { bw = max(bw, spacing * 0.35); tall = min(tall, spacing * 0.3); } vec3 p = vec3(a_pos.x * bw, a_pos.y * tall, a_pos.z * tall * (0.6 + 0.8 * h4)); vec3 n = vec3(0.0, 0.3, 1.0); diff --git a/packages/ludic.render3d/shaders/impostor.frag b/packages/ludic.render3d/shaders/impostor.frag index f26ac71f..b181f0f7 100644 --- a/packages/ludic.render3d/shaders/impostor.frag +++ b/packages/ludic.render3d/shaders/impostor.frag @@ -45,16 +45,27 @@ void main() { vec3 toCam = normalize(u_cam_pos - v_wpos); toCam.y = 0.0; toCam = normalize(toCam); vec3 right = vec3(-toCam.z, 0.0, toCam.x); vec3 hull = normalize(right * q.x * 0.8 + vec3(0.0, 1.0, 0.0) * (q.y * 0.6 + 0.35) + toCam * 0.7); - n = normalize(mix(hull, n, mix(0.65, 0.35, far))); + // Keep the BAKED normal. This was mix(0.65, 0.35, far), so past a few hundred metres + // only a third of the foliage's own normal survived and two thirds was a smooth + // rounded shell - which shades evenly and turns a canopy into a flat coloured blob. + // Impostors draw nearly all the forest a player ever sees (turn them off and the + // valley is bare), so that shell was most of the tree line. + n = normalize(mix(hull, n, mix(0.88, 0.70, far))); float dist = length(v_wpos - u_cam_pos); float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z; vec3 alb = a.rgb * u_tint * (0.85 + 0.3 * fract(v_seed * 7.13)) * regionTint(v_wpos, 0.4); - // a distant stand reads as a dark mass, not as bright separate sprites - alb = mix(alb, alb * vec3(0.72, 0.78, 0.72), far); + // A distant stand reads as a dark mass, not as bright separate sprites - but this was + // also pulling the colour out of it. Darken without desaturating: the same factor on + // all three channels, and a touch of saturation put back, because the thing that makes + // a far tree line read as forest rather than as a wash is that it is still GREEN. + alb = mix(alb, alb * 0.72, far); + float lum = dot(alb, vec3(0.2126, 0.7152, 0.0722)); + alb = mix(vec3(lum), alb, 1.45); // conifer foliage is SATURATED, not grey-green + alb *= 0.78; // and it is dark: a spruce canopy is not a lawn // the card itself is the caster: look up the shadow a little toward the sun so it does not self-shadow float shadow = sunShadow(v_wpos + u_sun_dir * u_radius * 0.7, vec3(0, 1, 0), viewDepth); // crowns are dense: darken toward the centre of the card as a cheap interior occlusion - float interior = 1.0 - 0.45 * smoothstep(0.9, 0.3, abs(q.x)) * smoothstep(1.0, 0.2, v_uv.y); + float interior = 1.0 - 0.22 * smoothstep(0.9, 0.3, abs(q.x)) * smoothstep(1.0, 0.2, v_uv.y); // ground contact: the lowest part of anything sitting on the ground is occluded by it // (a boulder's underside, a trunk's base); without it a far boulder is a sticker on the grass interior *= mix(0.55, 1.0, smoothstep(0.0, 0.3, v_uv.y)); diff --git a/packages/ludic.render3d/shaders/model.frag b/packages/ludic.render3d/shaders/model.frag index 14448b38..41257a24 100644 --- a/packages/ludic.render3d/shaders/model.frag +++ b/packages/ludic.render3d/shaders/model.frag @@ -18,6 +18,9 @@ uniform float u_emissive; // self-lit (a flame): albedo added back after sha #ifdef BLADE uniform vec3 u_blade_base; uniform vec3 u_blade_tip; +uniform sampler2D u_blade_tex; // straightened photographic blades, side by side +uniform float u_blade_cols; // how many are in it +uniform float u_blade_tex_on; #endif #ifdef CARD uniform float u_cull; // the layer's cull distance (m); 0 = none @@ -80,6 +83,33 @@ void main() { alb = mix(u_blade_base, u_blade_tip, t * t) * regionTint(v_wpos, 0.35); float dry = smoothstep(0.7, 0.8, fract(v_seed * 3.17)); alb = mix(alb, vec3(0.40, 0.36, 0.13) * (0.45 + 0.55 * t), dry * 0.75); + // A REAL BLADE, if the game gave us one. Each blade picks a column of the atlas from + // its own seed and runs v from sheath to tip, so a meadow is eight different plants + // rather than one plant ten thousand times. The photograph carries the midrib, the + // olive-to-straw run and the dry browning; the gradient above stays as the tint that + // the season and the lushness drive, so nothing that used to control the colour stops + // working - the picture multiplies it rather than replacing it. + if (u_blade_tex_on > 0.5) { + float col = floor(fract(v_seed * 7.31) * u_blade_cols); + vec2 buv = vec2((col + clamp(v_uv.x, 0.0, 1.0)) / u_blade_cols, 1.0 - t); + vec3 photo = texture(u_blade_tex, buv).rgb; + // Normalised by the atlas's OWN MEAN LUMINANCE - a measured constant, 0.3736 over the + // opaque pixels - and not by each pixel's mean. Dividing by the per-pixel mean was the + // bug: it cancels exactly the thing the photograph was fetched for. What survives is + // the hue ratio, so every blade comes back out at the same brightness and the midrib, + // the dry browning and the sheath-to-tip run all vanish. It looked like a faint tint + // over the old procedural blade, which is precisely what it was. + // Blended, not applied whole. At full strength a photo pixel brighter than the + // atlas's mean is multiplied by up to 1.9, and the bright blades came out white - + // straws in a green sward. Three quarters of the photograph keeps the midrib, the + // browning and the sheath-to-tip run and leaves the extremes alone. + // CLAMPED. The atlas's mean luminance is 0.3736, so a blade pixel brighter than the + // mean is multiplied by up to 2.7 and comes out white - and a white blade is the one + // thing grass never is. Holding the factor to 1.15 keeps the midrib and the browning, + // which are the parts of the photograph worth having, and refuses the bleach. + vec3 g = photo * (1.0 / 0.3736); + alb *= mix(vec3(1.0), min(g, vec3(1.15)), 0.85); + } float patchy = fbm(v_wpos.xz * 0.045, 3) * 0.5 + 0.5; alb *= mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy); // a far tuft is a patch of the meadow, darker than a lit blade tip and never straw @@ -87,7 +117,15 @@ void main() { // a rounded cross-section reads softer than a flat card vec3 side = normalize(cross(N, vec3(0.0, 1.0, 0.0)) + vec3(1e-4)); n = (v_hull < 0.0) ? N : normalize(N + side * (v_uv.x * 2.0 - 1.0) * 0.6); - arm = vec3(mix(0.2, 1.0, t * t), 0.85, 0.0); + // A blade's own ambient occlusion. This was mix(0.2, 1.0, t*t): 80% occluded at the + // sheath and still 60% at half height, because t*t holds the curve down. Measured in a + // walking-distance shot the blades came out at 24-31 of 255 against a ground of 143 - + // near-black on light earth - and at that contrast the eye reads every blade as a hard + // EDGE rather than as a mass of vegetation, whatever shape it is. No albedo can answer + // an occlusion term; grass albedo is capped near 0.5 and this was dividing it by five. + // A blade is a thin thing standing in open air: shaded at the root by its neighbours, + // not buried. + arm = vec3(mix(0.55, 1.0, t), 0.85, 0.0); #elif defined(CARD) // thin grass is lit from either side: face the card toward the sun before shading if (dot(N, u_sun_dir) < 0.0) N = -N; @@ -156,8 +194,20 @@ void main() { // The quake made visible. A turning aspen leaf shows its pale, almost white underside, so // the crown does not merely move - it GLITTERS, leaf by leaf, and that is what reads at a // distance and in a still frame. Toward the viewer is the pale side; away is the face. - if (v_quake > 0.0) alb = mix(alb, alb * 0.55 + vec3(0.42, 0.45, 0.33), min(v_quake * 2.6, 0.75)); - else if (v_quake < 0.0) alb *= 1.0 + v_quake * 0.28; // the dark half stays gentle: a crown should shimmer, not flicker + // The quake, as a LIGHTENING rather than a repaint. This used to mix up to 75% toward + // a fixed near-white (0.42,0.45,0.33), which was written for a hand-painted leaf atlas + // that had no pale underside of its own. On a photographed leaf it does not read as a + // turning leaf at all - it reads as parts of the tree going WHITE, because that is + // exactly what it does: it replaces three quarters of the leaf's colour with a constant. + // Nothing in a wood turns white in the sun. A real turning leaf shows a paler, greyer + // version of ITSELF, so lift and desaturate the leaf's own colour instead. + if (v_quake > 0.0) { + float q = min(v_quake * 2.6, 0.75); + float l = dot(alb, vec3(0.2126, 0.7152, 0.0722)); + alb = mix(alb, mix(alb, vec3(l), 0.45) * 1.30, q); + } else if (v_quake < 0.0) { + alb *= 1.0 + v_quake * 0.28; // the dark half stays gentle: a crown should shimmer, not flicker + } #if defined(FOLIAGE) && !defined(BLADE) // Per-plant HUE, not only per-plant brightness. The line above varies value by +-15% and // nothing else, so a stand of one species was one colour at fifteen different exposures - diff --git a/packages/ludic.render3d/terrain.ludic b/packages/ludic.render3d/terrain.ludic index b1446f4b..12435552 100644 --- a/packages/ludic.render3d/terrain.ludic +++ b/packages/ludic.render3d/terrain.ludic @@ -502,6 +502,17 @@ function terrain_bind_prog(p: int) -> void { sky_bind_lighting(p) shadow_bind(p) fog_bind(p) + # GROUND IS NOT A MIRROR, and this has to come AFTER fog_bind, which hands every + # program u_spec_scale = 1. That is right for water and for a varnished prop and wrong + # for a hillside: the image-based specular lays a broad reflection of a bright sky over + # every square metre of rock and meadow and washes them toward the sky's own colour. + # It is why the range named for the colour of its rock rendered pale lilac rather than + # maroon - the maroon was under a sheet of reflected sky. Foliage already gets 0.05. + # + # Set before fog_bind it measured as EXACTLY zero pixels changed, which is the same + # shape of mistake as setting r3d_fog_scale before gfx_apply: the value was right and + # something downstream put it back. + u_f(gpu_uniform(p, "u_spec_scale"), fl(0.22)) u_v3(gpu_uniform(p, "u_cam_pos"), cam_pos) u_f(gpu_uniform(p, "u_grid"), fi(CD_G)) # The ground reads its sun visibility out of the buffer tersun.frag filled, and has no diff --git a/runtime/native/gl.ll b/runtime/native/gl.ll index c28c9967..5b7b6f74 100644 --- a/runtime/native/gl.ll +++ b/runtime/native/gl.ll @@ -352,6 +352,28 @@ entry: ; macOS arm64: struct timeval is { time_t tv_sec (i64), suseconds_t tv_usec (i32) } %struct.timeval64 = type { i64, i32 } declare i32 @gettimeofday(ptr, ptr) +declare i32 @nanosleep(ptr, ptr) + +; Give the CPU back for `us` microseconds. A frame limiter needs this: without a way to wait, a +; cap can only be a spin, which burns a core and cooks a laptop. The caller sleeps SHORT of its +; target and spins the last stretch, because no OS sleep is exact. +define void @gl_sleep_us(i64 %us) { +entry: + %pos = icmp sgt i64 %us, 0 + br i1 %pos, label %go, label %out +go: + %ts = alloca [16 x i8], align 8 + %sec = sdiv i64 %us, 1000000 + %rem = srem i64 %us, 1000000 + %nsec = mul i64 %rem, 1000 + store i64 %sec, ptr %ts + %np = getelementptr i8, ptr %ts, i64 8 + store i64 %nsec, ptr %np + %r = call i32 @nanosleep(ptr %ts, ptr null) + br label %out +out: + ret void +} define i64 @gl_now_us() { entry: diff --git a/runtime/native/gl.ludic b/runtime/native/gl.ludic index ad36ded2..6617d7b9 100644 --- a/runtime/native/gl.ludic +++ b/runtime/native/gl.ludic @@ -32,6 +32,7 @@ extern function win_toggle_fullscreen() = "win_toggle_fullscreen" extern function cgl_offscreen() -> int = "cgl_offscreen" # wall clock in microseconds — the only sub-second clock available to a Ludic program extern function gl_now_us() -> long = "gl_now_us" +extern function gl_sleep_us(us: long) -> void = "gl_sleep_us" extern function fx_to_f32(fx: fixed) -> int = "fx_to_f32" extern function f32_to_fx(bits: int) -> fixed = "f32_to_fx" extern function mem_off(p: pointer, off: int) -> pointer = "mem_off" diff --git a/runtime/native/gl_win.ll b/runtime/native/gl_win.ll index ed789ed1..8f64fded 100644 --- a/runtime/native/gl_win.ll +++ b/runtime/native/gl_win.ll @@ -30,6 +30,10 @@ declare i32 @wglMakeCurrent(ptr, ptr) declare i32 @wglDeleteContext(ptr) declare ptr @wglGetProcAddress(ptr) declare i32 @QueryPerformanceCounter(ptr) +declare void @Sleep(i32) +declare ptr @CreateWaitableTimerExW(ptr, ptr, i32, i32) +declare i32 @SetWaitableTimerEx(ptr, ptr, i32, ptr, ptr, ptr, i32) +declare i32 @WaitForSingleObject(ptr, i32) declare i32 @QueryPerformanceFrequency(ptr) declare i32 @lgl_win_load() declare void @llvm.memcpy.p0.p0.i64(ptr, ptr, i64, i1) @@ -414,6 +418,55 @@ entry: ; gl.ll reads gettimeofday; Windows has QueryPerformanceCounter, which is ; monotonic as well as fine-grained. Split into whole seconds and remainder so ; the multiply cannot overflow however long the machine has been up. +; Give the CPU back for `us` microseconds (gl.ll's gl_sleep_us on Windows). +; +; Not Sleep and not timeBeginPeriod: Sleep takes whole milliseconds and the scheduler's default +; granularity can be 15 ms, which no 60 fps limiter survives, and timeBeginPeriod lives in winmm, +; which this runtime does not link. A HIGH RESOLUTION waitable timer is kernel32 and is accurate to +; well under a millisecond. Older Windows without the high-resolution flag falls back to Sleep, and +; the caller's spin covers the slack either way. +@G_timer = internal global ptr null +define void @gl_sleep_us(i64 %us) { +entry: + %pos = icmp sgt i64 %us, 0 + br i1 %pos, label %get, label %out +get: + %have = load ptr, ptr @G_timer + %need = icmp eq ptr %have, null + br i1 %need, label %make, label %ready +make: + ; CREATE_WAITABLE_TIMER_HIGH_RESOLUTION 2, TIMER_ALL_ACCESS 0x1F0003 + %t = call ptr @CreateWaitableTimerExW(ptr null, ptr null, i32 2, i32 2031619) + store ptr %t, ptr @G_timer + br label %ready +ready: + %h = load ptr, ptr @G_timer + %nohandle = icmp eq ptr %h, null + br i1 %nohandle, label %doze, label %wait +wait: + ; a NEGATIVE due time is relative, in 100 ns units + %due = alloca i64, align 8 + %hundreds = mul i64 %us, 10 + %neg = sub i64 0, %hundreds + store i64 %neg, ptr %due + %set = call i32 @SetWaitableTimerEx(ptr %h, ptr %due, i32 0, ptr null, ptr null, ptr null, i32 0) + %setok = icmp ne i32 %set, 0 + br i1 %setok, label %block, label %doze +block: + %w = call i32 @WaitForSingleObject(ptr %h, i32 -1) + br label %out +doze: + %ms = sdiv i64 %us, 1000 + %mspos = icmp sgt i64 %ms, 0 + br i1 %mspos, label %ms_go, label %out +ms_go: + %ms32 = trunc i64 %ms to i32 + call void @Sleep(i32 %ms32) + br label %out +out: + ret void +} + define i64 @gl_now_us() { entry: %c = alloca i64, align 8 diff --git a/runtime/native/input.ludic b/runtime/native/input.ludic index f0281230..fb7dfd8f 100644 --- a/runtime/native/input.ludic +++ b/runtime/native/input.ludic @@ -415,6 +415,32 @@ function input_key_label(k: int) -> string { if c <= 0 { return "" } return in_utf8(c) } +# What the player has TYPED since the last call, as UTF-8. This is a different question from which +# keys are DOWN, and both are needed: a binding is a physical key (input_key_down, so WASD is where +# W-A-S-D sit on any layout), while text is whatever the layout, the modifiers and any dead key +# actually produce. Building text out of key codes instead meant a Turkish player could not type +# c-cedilla, g-breve, dotless i, o-umlaut, s-cedilla or u-umlaut anywhere - their own name included. +# Empty headless, and where the platform has no text channel. +var in_text_buf: words = null +function input_text() -> string { + if not is_windowed() { return "" } + if in_text_buf == null { in_text_buf = words(64) } + let n = win_text(in_text_buf, 64) + if n <= 0 { return "" } + var out = "" + var i = 0 + while i < n { + var c = in_text_buf[i] + # a code point outside the BMP arrives as a surrogate PAIR - two units, one character + if c >= 55296 and c < 56320 and i + 1 < n { + let lo = in_text_buf[i + 1] + if lo >= 56320 and lo < 57344 { c = 65536 + ((c - 55296) * 1024) + (lo - 56320); i = i + 1 } + } + out = out + in_utf8(c) + i = i + 1 + } + return out +} # one code point as a UTF-8 string function in_utf8(c: int) -> string { let out = bytes(5) diff --git a/runtime/native/win32.ll b/runtime/native/win32.ll index 09166316..81cd4bd7 100644 --- a/runtime/native/win32.ll +++ b/runtime/native/win32.ll @@ -31,8 +31,17 @@ ; The process is per-monitor DPI aware: a client pixel is a screen pixel, the ; backing scale is 1, and a 1280x720 window is 1280x720 pixels on any display. ; -; Keys are read from the message queue before dispatch rather than from WM_CHAR: -; TranslateMessage is never called, so no character messages are produced. The held +; The window is a UNICODE window - RegisterClassExW, CreateWindowExW, PeekMessageW, +; DispatchMessageW, DefWindowProcW - and that is not cosmetic: only a Unicode window's WM_CHAR +; carries a UTF-16 code unit. Registered the ANSI way, TranslateMessage hands back one byte in the +; machine's code page, and a Turkish s-cedilla has nowhere to go. +; +; Keys are read from the message queue before dispatch rather than from WM_CHAR, so a BINDING is +; always the physical key. TranslateMessage IS called for key messages now, which adds WM_CHAR +; alongside - that is the only way to know what the player actually TYPED. Bindings read the +; scancode before dispatch and are unaffected; typing reads the characters. A player on a Turkish +; layout could not type c-cedilla, g-breve, dotless i, o, s-cedilla or u-umlaut at all before this, +; because the game built text from US-layout key CODES - their own name was unreachable. The held ; set is keyed by the scancode in lParam, not by the virtual key: a virtual key is ; the layout's idea of the key, so on AZERTY the key where W is printed on a US ; board arrives as VK_Z, and an input method turns every letter into VK_PROCESSKEY @@ -45,15 +54,27 @@ declare ptr @GetModuleHandleA(ptr) declare i16 @RegisterClassExA(ptr) +declare i16 @RegisterClassExW(ptr) declare ptr @CreateWindowExA(i32, ptr, ptr, i32, i32, i32, i32, i32, ptr, ptr, ptr, ptr) +declare ptr @CreateWindowExW(i32, ptr, ptr, i32, i32, i32, i32, i32, ptr, ptr, ptr, ptr) declare i64 @DefWindowProcA(ptr, i32, i64, i64) +declare i64 @DefWindowProcW(ptr, i32, i64, i64) declare i32 @ShowWindow(ptr, i32) +declare i64 @GetWindowLongPtrW(ptr, i32) +declare i64 @SetWindowLongPtrW(ptr, i32, i64) +declare i32 @SetWindowPos(ptr, ptr, i32, i32, i32, i32, i32) +declare i32 @GetWindowRect(ptr, ptr) +declare i32 @EnumDisplayDevicesW(ptr, i32, ptr, i32) +declare i32 @EnumDisplaySettingsW(ptr, i32, ptr) declare i32 @SetForegroundWindow(ptr) declare i32 @BringWindowToTop(ptr) declare i32 @SetWindowTextW(ptr, ptr) declare i32 @MultiByteToWideChar(i32, i32, ptr, i32, ptr, i32) declare i32 @PeekMessageA(ptr, ptr, i32, i32, i32) +declare i32 @PeekMessageW(ptr, ptr, i32, i32, i32) declare i64 @DispatchMessageA(ptr) +declare i64 @DispatchMessageW(ptr) +declare i32 @TranslateMessage(ptr) declare ptr @GetDC(ptr) declare i32 @AdjustWindowRectEx(ptr, i32, i32, i32) declare i32 @GetSystemMetrics(i32) @@ -81,6 +102,11 @@ declare i32 @XInputGetState(i32, ptr) declare void @llvm.memset.p0.i64(ptr, i8, i64, i1) @.w_class = private unnamed_addr constant [12 x i8] c"LudicWindow\00" +; the same name as UTF-16. A window is a UNICODE window only if its class was registered with +; RegisterClassExW, and only a Unicode window gets WM_CHAR carrying a UTF-16 code unit - on an +; ANSI window TranslateMessage hands back a byte in the machine's code page, which has nowhere to +; put a Turkish s-cedilla. That single fact is why a player could not type their own name. +@.w_classw = private unnamed_addr constant [24 x i8] c"L\00u\00d\00i\00c\00W\00i\00n\00d\00o\00w\00\00\00" ; shared with win32_gl.ll, which declares them external @W_hwnd = global ptr null @@ -91,6 +117,11 @@ declare void @llvm.memset.p0.i64(ptr, i8, i64, i1) @W_fb = internal global ptr null @W_key = internal global i32 0 +; what the player has typed since the game last took it: UTF-16 code units, oldest first. 64 is far +; more than a frame can produce, and an overflow drops the newest rather than wrapping onto the +; oldest - a dropped keystroke is better than a scrambled name. +@W_text = internal global [64 x i32] zeroinitializer +@W_textn = internal global i32 0 @W_running = internal global i32 1 @W_held = internal global [8 x i32] zeroinitializer @W_mx = internal global i32 0 @@ -139,7 +170,7 @@ nocur: %c = call ptr @SetCursor(ptr null) ret i64 1 def: - %r = call i64 @DefWindowProcA(ptr %hwnd, i32 %msg, i64 %wp, i64 %lp) + %r = call i64 @DefWindowProcW(ptr %hwnd, i32 %msg, i64 %wp, i64 %lp) ret i64 %r } @@ -183,6 +214,7 @@ done: define void @win_open(i32 %w, i32 %h, i32 %scale, ptr %title) { entry: %wc = alloca [80 x i8], align 8 + %tw = alloca [256 x i16], align 2 ; the window title as UTF-16, for CreateWindowExW %rc = alloca [16 x i8], align 4 %rid = alloca [16 x i8], align 8 store i32 %w, ptr @W_fbw @@ -219,8 +251,8 @@ make: %whc = getelementptr i8, ptr %wc, i64 40 store ptr %arrow, ptr %whc %wcn = getelementptr i8, ptr %wc, i64 64 - store ptr @.w_class, ptr %wcn - %atom = call i16 @RegisterClassExA(ptr %wc) + store ptr @.w_classw, ptr %wcn + %atom = call i16 @RegisterClassExW(ptr %wc) ; the client area is w x h at both scales; the frame goes round it %pw = mul i32 %gw, %ds %ph = mul i32 %gh, %ds @@ -249,7 +281,16 @@ make: %yneg = icmp slt i32 %y0, 0 %y = select i1 %yneg, i32 0, i32 %y0 ; WS_OVERLAPPEDWINDOW | WS_VISIBLE - %hwnd = call ptr @CreateWindowExA(i32 0, ptr @.w_class, ptr %title, i32 282001408, i32 %x, i32 %y, i32 %ww, i32 %wh, ptr null, ptr null, ptr %inst, ptr null) + ; the title arrives as UTF-8 and CreateWindowExW wants UTF-16. The buffer is zeroed first, so a + ; title that will not convert leaves an empty one rather than whatever was on the stack. + call void @llvm.memset.p0.i64(ptr %tw, i8 0, i64 512, i1 false) + %tnull = icmp eq ptr %title, null + br i1 %tnull, label %mkwin, label %conv +conv: + %cvn = call i32 @MultiByteToWideChar(i32 65001, i32 0, ptr %title, i32 -1, ptr %tw, i32 255) + br label %mkwin +mkwin: + %hwnd = call ptr @CreateWindowExW(i32 0, ptr @.w_classw, ptr %tw, i32 282001408, i32 %x, i32 %y, i32 %ww, i32 %wh, ptr null, ptr null, ptr %inst, ptr null) %nowin = icmp eq ptr %hwnd, null br i1 %nowin, label %out, label %show show: @@ -305,6 +346,164 @@ out: ; The game's window leaves the screen and comes back with its GL context or Vulkan surface ; untouched: a launcher steps aside while the game it started runs. A hidden window is ; still a window, so the run goes on. Both are no-ops before there is a window. +; App.window_fixed(on): take the resize grip and the maximise button off this window, or put them +; back. The LAUNCHER is not the game - it is a small fixed panel - but it carried the game's name in +; huge letters and a picture of the Bells, so players took it for the game and tried to make it full +; screen, then wondered why the video settings they had just changed had done nothing. A window you +; cannot maximise says what it is before any label does. +; ---- which display the window is on --------------------------------------------------------- +; Enumerated by INDEX, not through EnumDisplayMonitors, which wants a callback: EnumDisplayDevicesW +; walks the adapters by number and EnumDisplaySettingsW gives each one's position and size, so no +; function pointer has to cross the IR boundary. Only displays attached to the desktop count, so the +; numbering matches what the player sees in Windows' own display settings. +; +; DISPLAY_DEVICEW: cb 0, DeviceName 4 (32 wide chars), StateFlags 324. +; DEVMODEW: dmSize 68, dmFields 72, dmPosition 76 (x) 80 (y), dmPelsWidth 156, dmPelsHeight 160. +; DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 1; ENUM_CURRENT_SETTINGS = -1. +define i32 @win_monitor_rect(i32 %want, ptr %out) { +entry: + %dd = alloca [840 x i8], align 8 + %dm = alloca [220 x i8], align 8 + br label %loop +loop: + %j = phi i32 [ 0, %entry ], [ %j1, %cont ] + %found = phi i32 [ 0, %entry ], [ %found2, %cont ] + %toomany = icmp sge i32 %j, 64 + br i1 %toomany, label %none, label %ask +ask: + call void @llvm.memset.p0.i64(ptr %dd, i8 0, i64 840, i1 false) + store i32 840, ptr %dd + %ok = call i32 @EnumDisplayDevicesW(ptr null, i32 %j, ptr %dd, i32 0) + %gone = icmp eq i32 %ok, 0 + br i1 %gone, label %none, label %flags +flags: + %sfp = getelementptr i8, ptr %dd, i64 324 + %sf = load i32, ptr %sfp + %att = and i32 %sf, 1 + %live = icmp ne i32 %att, 0 + br i1 %live, label %count, label %skip +count: + %isit = icmp eq i32 %found, %want + br i1 %isit, label %settings, label %inc +settings: + call void @llvm.memset.p0.i64(ptr %dm, i8 0, i64 220, i1 false) + %szp = getelementptr i8, ptr %dm, i64 68 + store i16 220, ptr %szp + %namep = getelementptr i8, ptr %dd, i64 4 + %got = call i32 @EnumDisplaySettingsW(ptr %namep, i32 -1, ptr %dm) + %bad = icmp eq i32 %got, 0 + br i1 %bad, label %none, label %fill +fill: + %pxp = getelementptr i8, ptr %dm, i64 76 + %pyp = getelementptr i8, ptr %dm, i64 80 + %pwp = getelementptr i8, ptr %dm, i64 156 + %php = getelementptr i8, ptr %dm, i64 160 + %px = load i32, ptr %pxp + %py = load i32, ptr %pyp + %pw = load i32, ptr %pwp + %ph = load i32, ptr %php + store i32 %px, ptr %out + %o1 = getelementptr i32, ptr %out, i32 1 + store i32 %py, ptr %o1 + %o2 = getelementptr i32, ptr %out, i32 2 + store i32 %pw, ptr %o2 + %o3 = getelementptr i32, ptr %out, i32 3 + store i32 %ph, ptr %o3 + ret i32 1 +inc: ; a display, but not the one asked for + %foundinc = add i32 %found, 1 + br label %cont +skip: ; an adapter with no desktop on it: not a display at all + br label %cont +cont: + %found2 = phi i32 [ %foundinc, %inc ], [ %found, %skip ] + %j1 = add i32 %j, 1 + br label %loop +none: + ret i32 0 +} + +; how many displays the desktop is spread over +define i32 @win_monitor_count() { +entry: + %r = alloca [16 x i8], align 4 + br label %loop +loop: + %i = phi i32 [ 0, %entry ], [ %i1, %more ] + %ok = call i32 @win_monitor_rect(i32 %i, ptr %r) + %has = icmp ne i32 %ok, 0 + br i1 %has, label %more, label %done +more: + %i1 = add i32 %i, 1 + br label %loop +done: + ret i32 %i +} +; Put the window on display `i`, centred. A player with two screens had no way to say which one the +; game should use: the window opened where it opened, and going full screen only ever covered the +; display it was already on. +define void @app_window_to_monitor(i32 %i) { +entry: + %h = load ptr, ptr @W_hwnd + %none = icmp eq ptr %h, null + br i1 %none, label %out, label %go +go: + %mr = alloca [16 x i8], align 4 + %wr = alloca [16 x i8], align 4 + %ok = call i32 @win_monitor_rect(i32 %i, ptr %mr) + %bad = icmp eq i32 %ok, 0 + br i1 %bad, label %out, label %move +move: + %gw = call i32 @GetWindowRect(ptr %h, ptr %wr) + %l = load i32, ptr %wr + %t1p = getelementptr i32, ptr %wr, i32 1 + %t = load i32, ptr %t1p + %r2p = getelementptr i32, ptr %wr, i32 2 + %rr = load i32, ptr %r2p + %b3p = getelementptr i32, ptr %wr, i32 3 + %bb = load i32, ptr %b3p + %ww = sub i32 %rr, %l + %wh = sub i32 %bb, %t + %mx = load i32, ptr %mr + %my1p = getelementptr i32, ptr %mr, i32 1 + %my = load i32, ptr %my1p + %mw2p = getelementptr i32, ptr %mr, i32 2 + %mw = load i32, ptr %mw2p + %mh3p = getelementptr i32, ptr %mr, i32 3 + %mh = load i32, ptr %mh3p + %dx = sub i32 %mw, %ww + %dy = sub i32 %mh, %wh + %hx = sdiv i32 %dx, 2 + %hy = sdiv i32 %dy, 2 + %nx = add i32 %mx, %hx + %ny = add i32 %my, %hy + ; SWP_NOSIZE 1 | SWP_NOZORDER 4 + %sp = call i32 @SetWindowPos(ptr %h, ptr null, i32 %nx, i32 %ny, i32 0, i32 0, i32 5) + br label %out +out: + ret void +} + +define void @app_window_fixed(i32 %on) { +entry: + %h = load ptr, ptr @W_hwnd + %none = icmp eq ptr %h, null + br i1 %none, label %out, label %go +go: + %st = call i64 @GetWindowLongPtrW(ptr %h, i32 -16) ; GWL_STYLE + ; WS_THICKFRAME 0x00040000 | WS_MAXIMIZEBOX 0x00010000 + %fixed = icmp ne i32 %on, 0 + %cleared = and i64 %st, -327681 ; ~0x00050000 + %restored = or i64 %st, 327680 ; | 0x00050000 + %new = select i1 %fixed, i64 %cleared, i64 %restored + %old = call i64 @SetWindowLongPtrW(ptr %h, i32 -16, i64 %new) + ; SWP_NOMOVE 2 | SWP_NOSIZE 1 | SWP_NOZORDER 4 | SWP_FRAMECHANGED 32 + %r = call i32 @SetWindowPos(ptr %h, ptr null, i32 0, i32 0, i32 0, i32 0, i32 39) + br label %out +out: + ret void +} + define void @app_window_hide() { entry: %h = load ptr, ptr @W_hwnd @@ -604,7 +803,7 @@ entry: %nohwnd = icmp eq ptr %hwnd, null br i1 %nohwnd, label %done, label %pump pump: - %got = call i32 @PeekMessageA(ptr %msg, ptr null, i32 0, i32 0, i32 1) ; PM_REMOVE + %got = call i32 @PeekMessageW(ptr %msg, ptr null, i32 0, i32 0, i32 1) ; PM_REMOVE %any = icmp ne i32 %got, 0 br i1 %any, label %handle, label %finish handle: @@ -625,8 +824,29 @@ handle: i32 517, label %rup ; WM_RBUTTONUP i32 522, label %wheel ; WM_MOUSEWHEEL i32 255, label %raw ; WM_INPUT + i32 258, label %wchar ; WM_CHAR ] +wchar: + ; wParam is one UTF-16 code unit. Control characters are not text: Backspace, Enter, Tab and Esc + ; are actions the menu kit reads as keys, and letting them through would put a glyph in the name. + %ch = trunc i64 %wp to i32 + %isctl = icmp ult i32 %ch, 32 + %isdel = icmp eq i32 %ch, 127 + %skip = or i1 %isctl, %isdel + br i1 %skip, label %pump, label %wput +wput: + %tn = load i32, ptr @W_textn + %room = icmp slt i32 %tn, 64 + br i1 %room, label %wstore, label %pump +wstore: + %tp = getelementptr [64 x i32], ptr @W_text, i64 0, i32 %tn + store i32 %ch, ptr %tp + %tn1 = add i32 %tn, 1 + store i32 %tn1, ptr @W_textn + br label %pump kdown: + ; produces the WM_CHAR above for whatever this key types on the player's own layout + %tm = call i32 @TranslateMessage(ptr %msg) %hv = call i32 @w_keyval(i64 %wp, i64 %lp) call void @win_held_bit(i32 %hv, i32 1) %isf4 = icmp eq i64 %wp, 115 ; VK_F4 @@ -714,7 +934,7 @@ rawadd: store i32 %ay2, ptr @W_rawdy br label %dispatch dispatch: - %dm = call i64 @DispatchMessageA(ptr %msg) + %dm = call i64 @DispatchMessageW(ptr %msg) br label %pump finish: call void @win_cursor_maintain() @@ -775,6 +995,31 @@ entry: ret void } +; Take what has been typed since the last call: UTF-16 code units into `out` (one per word), +; returning how many. Draining here rather than in the pump keeps a frame that never asks from +; growing the ring for ever. +define i32 @win_text(ptr %out, i32 %max) { +entry: + %n = load i32, ptr @W_textn + %fits = icmp sle i32 %n, %max + %cnt = select i1 %fits, i32 %n, i32 %max + br label %loop +loop: + %i = phi i32 [ 0, %entry ], [ %i1, %body ] + %go = icmp slt i32 %i, %cnt + br i1 %go, label %body, label %done +body: + %sp = getelementptr [64 x i32], ptr @W_text, i64 0, i32 %i + %v = load i32, ptr %sp + %dp = getelementptr i32, ptr %out, i32 %i + store i32 %v, ptr %dp + %i1 = add i32 %i, 1 + br label %loop +done: + store i32 0, ptr @W_textn + ret i32 %cnt +} + define void @win_held(ptr %out) { entry: br label %loop @@ -1337,7 +1582,7 @@ endp: %e = call i32 @EndPaint(ptr %hwnd, ptr %ps) ret i64 0 def: - %d = call i64 @DefWindowProcA(ptr %hwnd, i32 %msg, i64 %wp, i64 %lp) + %d = call i64 @DefWindowProcW(ptr %hwnd, i32 %msg, i64 %wp, i64 %lp) ret i64 %d } diff --git a/selfhost/backend/emit_call.ludic b/selfhost/backend/emit_call.ludic index 3b4d3597..87f953e6 100644 --- a/selfhost/backend/emit_call.ludic +++ b/selfhost/backend/emit_call.ludic @@ -258,6 +258,27 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val { if g_windowed { emit(` call void @lp_app_icon(ptr {ip})\n`) } return val("0", "void") } + if (meth == "monitor_count") { + # how many displays the desktop spans (1 where the platform does not say) + use_pak() + if g_windowed { return val(emit_bind("call i32 @win_monitor_count()"), "int") } + return val("1", "int") + } + if (meth == "window_to_monitor") { + # put the window on that display, centred + use_pak() + let mp = arg_code(e, 0) + if g_windowed { emit(` call void @app_window_to_monitor(i32 {mp})\n`) } + return val("0", "void") + } + if (meth == "window_fixed") { + # take the resize grip and the maximise button off this window (or put them back): the + # launcher is a fixed panel, not the game, and a window you cannot maximise says so. + use_pak() + let fp = arg_code(e, 0) + if g_windowed { emit(` call void @app_window_fixed(i32 {fp})\n`) } + return val("0", "void") + } if (meth == "window_hide") or (meth == "window_show") { # Hide / show the game's own window (not the splash) without tearing down its # GL / Vulkan surface - a launcher steps aside while the game it started runs. @@ -458,6 +479,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val { if (meth == "key_pressed") { bare = "input_key_pressed"; push(labels, "key") } if (meth == "key_released") { bare = "input_key_released"; push(labels, "key") } if (meth == "key_label") { bare = "input_key_label"; push(labels, "key") } + if (meth == "text") { bare = "input_text" } # what was TYPED, UTF-8 if (meth == "press") { bare = "input_press"; push(labels, "key") } if (meth == "release") { bare = "input_release"; push(labels, "key") } if (meth == "axis") { bare = "input_axis"; push(labels, "neg"); push(labels, "pos") } diff --git a/selfhost/backend/emit_head.ludic b/selfhost/backend/emit_head.ludic index 8a251737..3e51cba9 100644 --- a/selfhost/backend/emit_head.ludic +++ b/selfhost/backend/emit_head.ludic @@ -117,6 +117,7 @@ function emit_header() -> void { emith("declare void @win_close()\n") emith("declare void @win_held(ptr)\n") emith("declare i32 @win_key_char(i32)\n") + emith("declare i32 @win_text(ptr, i32)\n") emith("declare void @win_mouse(ptr)\n") emith("declare void @win_pad(ptr)\n") emith("declare void @win_touch(ptr)\n") diff --git a/selfhost/backend/emit_intrin2.ludic b/selfhost/backend/emit_intrin2.ludic index 594faef8..6844a03d 100644 --- a/selfhost/backend/emit_intrin2.ludic +++ b/selfhost/backend/emit_intrin2.ludic @@ -21,6 +21,7 @@ function is_intrinsic2(name: pointer) -> bool { if (name == "win_open") or (name == "win_poll") or (name == "win_present") { return true } if (name == "win_running") or (name == "win_close") { return true } if (name == "win_held") or (name == "win_mouse") { return true } # #50 device layer + if (name == "win_text") { return true } # typed characters if (name == "win_key_char") { return true } # Input.key_label if (name == "win_pad") or (name == "win_touch") { return true } # #51 gamepad / touch if (name == "win_cursor_mode") { return true } # #89 cursor capture @@ -75,6 +76,10 @@ function emit_intrinsic2(name: pointer, e: Node) -> Val { # what the active keyboard layout types on a held-set key position (windowed only, # DCE'd headless): a code point, 0 for none. if (name == "win_key_char") { let a = arg_code(e, 0); return val(emit_bind(`call i32 @win_key_char(i32 {a})`), "int") } + # what the player TYPED since the last call - UTF-16 code units into the caller's buffer, one per + # word, returning how many. The layout, the modifiers and any dead key are already applied, which + # is why a name field must read this rather than build text out of key codes. + if (name == "win_text") { let a = arg_code(e, 0); let b = arg_code(e, 1); return val(emit_bind(`call i32 @win_text(ptr {a}, i32 {b})`), "int") } # #51 — win_pad: 6 words/pad [connected, mask, lx, ly, rx, ry]; win_touch: 3 # words/point [active, x, y]. Both windowed-only, DCE'd headless. if (name == "win_pad") { let a = arg_code(e, 0); emit(" call void @win_pad(ptr "); emit(a); emit(")\n"); return val("0", "void") } diff --git a/selfhost/backend/stdlib/emit_pak.ludic b/selfhost/backend/stdlib/emit_pak.ludic index 47339157..799695ab 100644 --- a/selfhost/backend/stdlib/emit_pak.ludic +++ b/selfhost/backend/stdlib/emit_pak.ludic @@ -120,6 +120,9 @@ function emit_pak_prelude() -> void { emith("declare void @splash_show(ptr, i32, i32)\n") emith("declare void @splash_hide()\n") emith("declare void @app_set_icon(ptr, i32)\n") + emith("declare void @app_window_fixed(i32)\n") + emith("declare i32 @win_monitor_count()\n") + emith("declare void @app_window_to_monitor(i32)\n") emith("declare void @app_window_hide()\n") emith("declare void @app_window_show()\n") }