aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio')
-rw-r--r--public/bluebey-studio/src/faceArt.js54
-rw-r--r--public/bluebey-studio/src/panel.js34
-rw-r--r--public/bluebey-studio/src/presets.js4
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,