diff options
Diffstat (limited to 'public')
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 54 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 34 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 4 |
3 files changed, 65 insertions, 27 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index c5eca6e..6a870fa 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -485,11 +485,24 @@ function rotate(points, pivot, degrees) { * shaded face behind it. That is what makes the eyelid blend perfectly without * baking a face-coloured background into the texture. */ -function clipEye(ctx, eye, open, lower, flat = false) { +function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { circlePath(ctx, eye.cx, eye.cy, eye.r); ctx.clip(); - const upper = 1 - clamp(open, 0, 1); + const tilt = (clamp(tiltDeg, -45, 45) * Math.PI) / 180; + const rotated = Math.abs(tilt) > 1e-4; + const spin = () => { + ctx.translate(eye.cx, eye.cy); + ctx.rotate(tilt); + ctx.translate(-eye.cx, -eye.cy); + }; + if (rotated) spin(); + + const lower = clamp(lowerLid, 0, 1); + // The upper lid travels all the way down to the lower lid's resting line, so + // the eye shuts at open = 0 whatever the lower lid is doing: a blink closes + // fully even on a squint. + const upper = (1 - lower) * (1 - clamp(open, 0, 1)); if (upper > 0.0005) { const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; const lowest = eye.cy - eye.r + 2 * eye.r * upper; @@ -510,7 +523,7 @@ function clipEye(ctx, eye, open, lower, flat = false) { ctx.clip(); } - const bottom = clamp(lower, 0, 1); + const bottom = lower; if (bottom > 0.0005) { const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; const highest = eye.cy + eye.r - 2 * eye.r * bottom; @@ -529,6 +542,13 @@ function clipEye(ctx, eye, open, lower, flat = false) { ctx.closePath(); ctx.clip(); } + + if (rotated) { + // Undo the rotation for the caller, but keep the clip we just set. + ctx.translate(eye.cx, eye.cy); + ctx.rotate(-tilt); + ctx.translate(-eye.cx, -eye.cy); + } } function lidPath(ctx, eye, amount, lower, flat = false) { @@ -1337,6 +1357,8 @@ export function drawEyes(ctx, p) { const heartTravel = EYE_LAYOUT.radius * 0.9; const lowerLid = clamp(style.lowerLid ?? 0, 0, 1); const lidFlat = (style.lidShape ?? 'curve') === 'flat'; + // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint). + const lidTilt = clamp(style.lidTilt ?? 0, -45, 45); // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here // rather than inside the per-eye loop below. 鼻ちょうちん comes along too. @@ -1403,7 +1425,7 @@ export function drawEyes(ctx, p) { // A heart is not an eyeball, so once the eye is fully open it is not clipped // to the eyeball circle - that is what lets a heart be much larger than the // iris. While the lid is partly closed the clip is still needed. - if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid * (1 - open), lidFlat); + if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose); const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax; let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel; @@ -1461,25 +1483,31 @@ export function drawEyes(ctx, p) { // Faded in as the lid starts to cover the eye, so opening the eye all the // way leaves the clean original artwork with no extra line. const lidFade = clamp((0.95 - open) / 0.1, 0, 1); + // The tilt is mirrored between the eyes, so a positive value reads the same + // way on both (an inward, angry-looking squint). + const eyeTilt = lidTilt * eye.towardNose; + // The upper lid line has to sit where the clip put it: it travels from the + // top of the eye down to the lower lid's resting line. + const upperAmt = lowerLid + open * (1 - lowerLid); if (lidFade > 0.01 || lowerLid > 0.01) { ctx.save(); + if (eyeTilt) { + ctx.translate(eye.cx, eye.cy); + ctx.rotate((eyeTilt * Math.PI) / 180); + ctx.translate(-eye.cx, -eye.cy); + } circlePath(ctx, eye.cx, eye.cy, eye.r); ctx.clip(); ctx.globalAlpha = line ? 1 : lidFade; ctx.strokeStyle = lineColor; ctx.lineWidth = lidWidth; if (lidFade > 0.01) { - lidPath(ctx, eye, open, false, lidFlat); + lidPath(ctx, eye, upperAmt, false, lidFlat); ctx.stroke(); } - // The lower lid fades out as the eye closes (the upper lid does the work), - // and the *line* has to use the same amount as the clip above - using the - // raw `lowerLid` left the line floating inside the eyeball, which read as a - // second (doubled) lid. - const lower = lowerLid * (1 - open); - if (lower > 0.01) { - ctx.globalAlpha = line ? 1 : lower; - lidPath(ctx, eye, lower, true, lidFlat); + if (lowerLid > 0.01) { + ctx.globalAlpha = line ? 1 : lowerLid; + lidPath(ctx, eye, lowerLid, true, lidFlat); ctx.stroke(); } ctx.restore(); diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index ce479c7..3c2dc15 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -646,6 +646,24 @@ export function buildPanel(app, root) { }], })); + // まぶたの形 and 目の傾き sit right next to 目を細める, so the narrowed eye can be + // tuned without hunting inside こまかい設定. + const lidShapeSegment = segmented({ + label: 'まぶたの形', + options: [ + { value: 'curve', label: '曲線' }, + { value: 'flat', label: '直線(平ら)' }, + ], + value: 'curve', + onChange: (value) => { state.face.eyes.lidShape = value; app.actions.refresh('face'); }, + }); + faceSection.add(lidShapeSegment.el); + const lidTiltSlider = slider({ + label: '目の傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (value) => { state.face.eyes.lidTilt = value; app.actions.refresh('face'); }, + }); + faceSection.add(lidTiltSlider.el); + const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・ハート・涙・まぶた)'); const irisScaleSlider = slider({ label: '瞳の大きさ', min: 0.4, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), @@ -677,15 +695,6 @@ export function buildPanel(app, root) { label: '下まぶた', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (value) => { state.face.eyes.lowerLid = value; app.actions.refresh('face'); }, }); - const lidShapeSegment = segmented({ - label: 'まぶたの形', - options: [ - { value: 'curve', label: '曲線' }, - { value: 'flat', label: '直線(平ら)' }, - ], - value: 'curve', - onChange: (value) => { state.face.eyes.lidShape = value; app.actions.refresh('face'); }, - }); eyeAdvanced.add(irisScaleSlider.el); eyeAdvanced.add(heartScaleSlider.el); eyeAdvanced.add(controlRow(null, heartWhiteToggle.el, { wide: true })); @@ -693,10 +702,8 @@ export function buildPanel(app, root) { eyeAdvanced.add(lookMaxSlider.el); eyeAdvanced.add(lidWidthSlider.el); eyeAdvanced.add(lowerLidSlider.el); - eyeAdvanced.add(lidShapeSegment.el); - eyeAdvanced.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。' - + '「目を細める」で、上まぶたを下げつつ下まぶたを上げた細目になります。' - + '「下まぶた」を上げると、下からもまぶたが持ち上がります。')); + eyeAdvanced.add(hint('「下まぶた」を上げると、下からもまぶたが持ち上がります。' + + '「まぶたの形」「目の傾き」「目を細める」は、この上の「目」にあります。')); const highlightToggle = check({ label: '瞳のハイライト', @@ -2701,6 +2708,7 @@ export function buildPanel(app, root) { lidWidthSlider.set(state.face.eyes.lidWidth ?? 14); lowerLidSlider.set(state.face.eyes.lowerLid ?? 0); lidShapeSegment.set(state.face.eyes.lidShape ?? 'curve'); + lidTiltSlider.set(state.face.eyes.lidTilt ?? 0); browToggle.set(state.face.eyes.brow.enabled); browSliders.angle.set(state.face.eyes.brow.angle); browSliders.height.set(state.face.eyes.brow.height); diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index 4ae17b3..0862fcd 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -204,8 +204,10 @@ export function defaultState() { lidWidth: 14, lowerLid: 0, // How the closing eyelid is drawn: `curve` is the soft, rounded lid; `flat` - // is a straight lid edge (the hard, narrowed look). See faceArt.js. + // is a straight lid edge (the hard, narrowed look). `lidTilt` rotates the + // narrowed eye. See faceArt.js. lidShape: 'curve', + lidTilt: 0, heartScale: 2, heartColor: '#e0344f', heartHideWhite: true, |
