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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ struct KeyboardStageEntrancePresentation: Equatable, Sendable {
case settled
}

static let holdDuration: TimeInterval = 1.50
static let holdDuration: TimeInterval = 2.00
static let transitionDuration: TimeInterval = 0.75
static let reducedMotionTransitionDuration: TimeInterval = 0.25

Expand Down
75 changes: 71 additions & 4 deletions Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct KeyboardStageInstance {
float4 parameters; // pressure, glow, opacity, border strength
float4 treatment; // rotation, corner ratio, shadow margin in pixels, reserved
float4 lighting; // illumination, transient glow, shadow strength, interaction level
float4 material; // wear level, stable per-key seed, reserved
};

struct KeyboardStageUniforms {
Expand All @@ -22,6 +23,7 @@ struct KeyboardStageUniforms {
float4 tuningB; // graze mask start, rim peak, rim power, emphasis rim floor
float4 tuningC; // well expansion, contact strength, cast strength, backlight
float4 tuningD; // legend emission min/max, legend base min/max
float4 tuningE; // fill light, fill specular, wear strength, highlight jitter
};

struct KeyboardStageLegendInstance {
Expand All @@ -34,6 +36,7 @@ struct KeyboardStageLegendInstance {
struct KeyboardStagePostUniforms {
float4 sourceAndBloomSize; // source width/height, bloom width/height
float4 entranceAndDirection; // progress, Reduce Motion flag, light direction.xy
float4 vignette; // strength, inner radius, stage windowX origin/width
};

struct KeyboardStageVertexOut {
Expand All @@ -48,6 +51,7 @@ struct KeyboardStageVertexOut {
float cornerRadiusPixels;
float2 stageUV;
float materialKind;
float2 material;
};

struct KeyboardStageLegendVertexOut {
Expand Down Expand Up @@ -111,6 +115,7 @@ vertex KeyboardStageVertexOut keypath_keyboard_stage_vertex(
0.5 - output.position.y * 0.5
);
output.materialKind = instance.treatment.w;
output.material = instance.material.xy;
return output;
}

Expand Down Expand Up @@ -321,6 +326,15 @@ fragment float4 keypath_keyboard_stage_fragment(
// instead of a seated key's bevel-and-well treatment.
bool isChip = input.materialKind >= 1.5;
float keyPress = isKey ? saturate(input.lighting.w) : 0.0;
// Real keycaps are not uniform: fingers polish high-traffic caps and no
// two caps sit at exactly the same angle. Wear and a stable per-key seed
// drive gloss, grain, and highlight variation; both derive from the key
// code, so the pattern never shifts between moments or launches.
float wear = isKey
? saturate(input.material.x) * saturate(uniforms.tuningE.z)
: 0.0;
float materialSeedA = keypath_hash(float2(input.material.y, 0.37));
float materialSeedB = keypath_hash(float2(input.material.y, 0.71));
bool reduceMotion = uniforms.entrance.y > 0.5;
float pixelExposure = keypath_cinematic_exposure(
uniforms.entrance.x,
Expand Down Expand Up @@ -497,6 +511,13 @@ fragment float4 keypath_keyboard_stage_fragment(
);
float faceMask = isKey ? (1.0 - bevel) * surfaceAlpha : 0.0;
float2 crownNormal = faceCoordinate * float2(0.055, 0.075) * faceMask;
// Each cap sits at a fractionally different angle, so its highlight lands
// slightly apart from its neighbors' — the variation that reads as
// photographed hardware instead of instanced geometry.
crownNormal += (float2(materialSeedA, materialSeedB) - 0.5)
* 0.055
* uniforms.tuningE.w
* faceMask;
float3 normal = normalize(float3(
distanceGradient.x * bevel * bevelDepth + crownNormal.x,
distanceGradient.y * bevel * bevelDepth + crownNormal.y,
Expand All @@ -516,7 +537,7 @@ fragment float4 keypath_keyboard_stage_fragment(
? sin(materialPixels.y * 1.82 + keypath_value_noise(materialPixels * 0.012) * 4.0)
* 0.0038
: 0.0;
float microNormalStrength = isDeck ? 0.026 : 0.014;
float microNormalStrength = isDeck ? 0.026 : 0.014 * (1.0 - wear * 0.45);
normal = normalize(normal + float3(
microX * microNormalStrength,
microY * microNormalStrength + brushedRidge,
Expand All @@ -541,7 +562,10 @@ fragment float4 keypath_keyboard_stage_fragment(
float viewDotHalf = saturate(halfVector.z);
float roughnessVariation = keypath_value_noise(materialPixels * (isDeck ? 0.032 : 0.058)) - 0.5;
float roughness = clamp(
(isDeck ? 0.31 : 0.47) + roughnessVariation * (isDeck ? 0.055 : 0.035),
(isDeck ? 0.31 : 0.47)
+ roughnessVariation * (isDeck ? 0.055 : 0.035)
- wear * 0.11
+ (isKey ? (materialSeedA - 0.5) * 0.05 : 0.0),
0.12,
0.82
);
Expand All @@ -560,7 +584,11 @@ fragment float4 keypath_keyboard_stage_fragment(
* microfacetVisibility
/ max(0.04, 4.0 * normalDotView * normalDotLight);
float specularPower = isDeck ? 88.0 : (isKey ? 42.0 : 34.0);
float specular = pow(saturate(dot(normal, halfVector)), specularPower);
float keySpecularGain = isKey
? (0.85 + materialSeedB * 0.30) * (1.0 + wear * 0.55)
: 1.0;
Comment thread
malpern marked this conversation as resolved.
float specular = pow(saturate(dot(normal, halfVector)), specularPower)
* keySpecularGain;
float bevelSpecular = pow(
saturate(dot(normal, halfVector)),
isDeck ? 112.0 : 64.0
Expand All @@ -581,7 +609,9 @@ fragment float4 keypath_keyboard_stage_fragment(
// legible even under a saturated accent fill.
surfaceColor *= mix(1.0, mix(1.05, 0.80, verticalPosition), pressure);
float crownHighlight = isKey
? pow(saturate(1.0 - length(faceCoordinate) * 0.52), 2.2) * faceMask
? pow(saturate(1.0 - length(faceCoordinate) * 0.52), 2.2)
* faceMask
* (1.0 + wear * 1.1)
: 0.0;
float lowerBevelCatch = isKey
? bevel * smoothstep(0.05, 0.82, distanceGradient.y)
Expand Down Expand Up @@ -613,6 +643,7 @@ fragment float4 keypath_keyboard_stage_fragment(
surfaceColor += microfacetSpecular
* neutralLight
* normalDotLight
* keySpecularGain
* mix(isDeck ? 0.032 : 0.018, isDeck ? 0.22 : 0.11, illumination);
surfaceColor += neutralLight
* crownHighlight
Expand Down Expand Up @@ -665,6 +696,25 @@ fragment float4 keypath_keyboard_stage_fragment(
* grazePool
* (warmMaterialResponse + warmBevelResponse);

// A dim cool fill from the lower left mirrors the second source of a
// studio rig: it lifts the shadow side just enough to give highlights a
// two-temperature complexity instead of a single clinical lobe.
float3 fillDirection = normalize(float3(-0.55, 0.35, 0.62));
float fillDiffuse = saturate(dot(normal, fillDirection));
float3 fillHalfVector = normalize(fillDirection + float3(0.0, 0.0, 1.0));
float fillSpecular = pow(
saturate(dot(normal, fillHalfVector)),
isDeck ? 60.0 : 40.0
);
// The dark room's materials sit near black in linear light, so the fill
// must stay a whisper there or it grays the whole scene; the settled
// light state can carry more of it invisibly.
float fillStrength = uniforms.tuningE.x * mix(0.10, 0.35, illumination);
surfaceColor += float3(0.52, 0.60, 0.76)
* fillStrength
* (fillDiffuse * (isDeck ? 0.050 : 0.034)
+ fillSpecular * bevel * uniforms.tuningE.y);

float edge = smoothstep(-2.2, -0.15, distance) * surfaceAlpha;
float edgeIllumination = mix(0.10, 0.72, illumination) + grazingBand * 0.56;
// In the dark room the lesson key's rim reads as a white bevel catching
Expand Down Expand Up @@ -1012,6 +1062,23 @@ fragment float4 keypath_keyboard_composite_fragment(
float3 straightRadiance = premultipliedRadiance / finalAlpha;
float3 mappedColor = keypath_highlight_shoulder(straightRadiance);

// The reference photograph sinks toward the frame corners. The falloff is
// computed in window space so it stays anchored to the dialog, and it
// relaxes as the light rises so the settled state keeps only a trace.
float2 windowUV = float2(
uniforms.vignette.z + input.uv.x * uniforms.vignette.w,
input.uv.y
);
float vignetteDistance = length((windowUV - float2(0.5)) * float2(1.25, 1.0));
float vignetteFalloff = smoothstep(
uniforms.vignette.y,
uniforms.vignette.y + 0.38,
vignetteDistance
)
* uniforms.vignette.x
* mix(1.0, 0.30, easedProgress);
mappedColor *= 1.0 - vignetteFalloff;

// Static sub-LSB noise prevents banding in the dark-to-light shoulder. It
// fades out before progress reaches one so the final light frame is clean.
float2 sourceSize = max(uniforms.sourceAndBloomSize.xy, float2(1.0));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ private struct KeyboardStageGPUInstance {
var parameters: SIMD4<Float>
var treatment: SIMD4<Float>
var lighting: SIMD4<Float>
var material: SIMD4<Float>
}

private struct KeyboardStageGPUUniforms {
Expand All @@ -21,6 +22,7 @@ private struct KeyboardStageGPUUniforms {
var tuningB: SIMD4<Float>
var tuningC: SIMD4<Float>
var tuningD: SIMD4<Float>
var tuningE: SIMD4<Float>
}

private struct KeyboardStageGPULegendInstance {
Expand All @@ -33,6 +35,7 @@ private struct KeyboardStageGPULegendInstance {
private struct KeyboardStageGPUPostUniforms {
var sourceAndBloomSize: SIMD4<Float>
var entranceAndDirection: SIMD4<Float>
var vignette: SIMD4<Float>
}

private struct KeyboardStageMetalRenderTargets {
Expand Down Expand Up @@ -199,7 +202,8 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se
tuningA: currentTuning.gpuVectorA,
tuningB: currentTuning.gpuVectorB,
tuningC: currentTuning.gpuVectorC,
tuningD: currentTuning.gpuVectorD
tuningD: currentTuning.gpuVectorD,
tuningE: currentTuning.gpuVectorE
)
var postUniforms = KeyboardStageGPUPostUniforms(
sourceAndBloomSize: SIMD4(
Expand All @@ -213,6 +217,12 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se
currentFrame.entrance.reduceMotion ? 1 : 0,
1,
0
),
vignette: SIMD4(
currentTuning.vignetteVector.x,
currentTuning.vignetteVector.y,
currentWindowX.x,
currentWindowX.y
)
)

Expand Down Expand Up @@ -320,7 +330,8 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se
tuningA: capturedTuning.gpuVectorA,
tuningB: capturedTuning.gpuVectorB,
tuningC: capturedTuning.gpuVectorC,
tuningD: capturedTuning.gpuVectorD
tuningD: capturedTuning.gpuVectorD,
tuningE: capturedTuning.gpuVectorE
)
var postUniforms = KeyboardStageGPUPostUniforms(
sourceAndBloomSize: SIMD4(
Expand All @@ -334,6 +345,12 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se
frame.entrance.reduceMotion ? 1 : 0,
1,
0
),
vignette: SIMD4(
capturedTuning.vignetteVector.x,
capturedTuning.vignetteVector.y,
windowX.x,
windowX.y
)
)

Expand Down Expand Up @@ -934,6 +951,8 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se
lighting: lighting.lighting(for: key),
cornerRatio: 0.31,
materialKind: 1,
wear: Self.wearLevel(for: key.keyCode),
seed: Self.materialSeed(for: key.keyCode),
drawableSize: drawableSize
)
})
Expand Down Expand Up @@ -1018,6 +1037,8 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se
lighting: KeyboardStageSurfaceLighting,
cornerRatio: Float,
materialKind: Float,
wear: Float = 0,
seed: Float = 0.5,
drawableSize: CGSize
) -> KeyboardStageGPUInstance {
let width = max(1, Float(drawableSize.width))
Expand Down Expand Up @@ -1050,10 +1071,36 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se
lighting.transientGlow,
lighting.shadowStrength,
interactionLevel
)
),
material: SIMD4(wear, seed, 0, 0)
)
}

/// Fingers polish real keycaps unevenly: home-row and thumb-cluster caps
/// go glossy first, common letters follow, and the number row keeps its
/// factory texture. Values are relative gloss, tuned by feel.
private static func wearLevel(for keyCode: UInt16) -> Float {
switch keyCode {
case 49: 0.85 // space
case 0, 1, 2, 3, 38, 40, 37, 41: 0.75 // a s d f j k l ;
case 57, 36, 51: 0.65 // caps lock, return, delete
case 56, 60, 55, 54, 58, 61, 59, 62, 63: 0.55 // shift, command, option, control, fn
case 14, 17, 31, 34, 45, 15, 32, 4: 0.5 // e t o i n r u h
case 12, 13, 16, 5, 35, 8, 9, 7, 6, 11, 46, 43, 47, 44: 0.35 // remaining letters/punct
case 18 ... 29, 50: 0.15 // number row
default: 0.3
}
}

/// A stable per-key seed so material variation and highlight jitter stay
/// fixed across moments, camera changes, and app launches.
private static func materialSeed(for keyCode: UInt16) -> Float {
var value = UInt32(keyCode) &* 2_654_435_761
value = (value ^ (value >> 16)) &* 2_246_822_519
value = value ^ (value >> 13)
return Float(value % 10000) / 10000
}

private func linearColor(_ color: KeyboardStageRGBA) -> SIMD4<Float> {
SIMD4(
linearComponent(color.red),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ struct KeyboardStageTuning: Equatable, Sendable {
/// Legend base-brightness range across the light axis in the dark hold.
var legendBaseMin: Float = 0.84
var legendBaseMax: Float = 1.05
/// Cool fill light from the lower left: overall and specular strength.
var fillLightStrength: Float = 0.95
var fillSpecularStrength: Float = 0.15
/// Per-key wear (polished crowns, calmer grain on high-traffic caps).
var wearStrength: Float = 1.5
/// Per-key highlight jitter (each cap catches the light slightly apart).
var highlightJitter: Float = 2.2
/// Window-space corner falloff; relaxes to 30% at the settled light.
var vignetteStrength: Float = 0.58
var vignetteInnerRadius: Float = 0.33

static let `default` = KeyboardStageTuning()

Expand All @@ -70,6 +80,16 @@ struct KeyboardStageTuning: Equatable, Sendable {
var gpuVectorD: SIMD4<Float> {
SIMD4(legendEmissionMin, legendEmissionMax, legendBaseMin, legendBaseMax)
}

var gpuVectorE: SIMD4<Float> {
SIMD4(fillLightStrength, fillSpecularStrength, wearStrength, highlightJitter)
}

/// Packed into the post-pass uniforms; the renderer appends the stage's
/// window mapping to the remaining two lanes.
var vignetteVector: SIMD2<Float> {
SIMD2(vignetteStrength, vignetteInnerRadius)
}
}

extension KeyboardStageTuning: Codable {
Expand All @@ -90,6 +110,12 @@ extension KeyboardStageTuning: Codable {
case legendEmissionMax
case legendBaseMin
case legendBaseMax
case fillLightStrength
case fillSpecularStrength
case wearStrength
case highlightJitter
case vignetteStrength
case vignetteInnerRadius
}

init(from decoder: any Decoder) throws {
Expand Down Expand Up @@ -119,6 +145,12 @@ extension KeyboardStageTuning: Codable {
legendEmissionMax = value(.legendEmissionMax, defaults.legendEmissionMax)
legendBaseMin = value(.legendBaseMin, defaults.legendBaseMin)
legendBaseMax = value(.legendBaseMax, defaults.legendBaseMax)
fillLightStrength = value(.fillLightStrength, defaults.fillLightStrength)
fillSpecularStrength = value(.fillSpecularStrength, defaults.fillSpecularStrength)
wearStrength = value(.wearStrength, defaults.wearStrength)
highlightJitter = value(.highlightJitter, defaults.highlightJitter)
vignetteStrength = value(.vignetteStrength, defaults.vignetteStrength)
vignetteInnerRadius = value(.vignetteInnerRadius, defaults.vignetteInnerRadius)
}

/// Loads overrides from a JSON file; unknown keys are ignored and missing
Expand Down
Loading
Loading